ops: prepare production deployment
This commit is contained in:
parent
a14a5d2471
commit
0ca75d5eff
9 changed files with 162 additions and 32 deletions
|
|
@ -9,3 +9,4 @@ KEYCHAIN_DB_PATH=apps/api/data/keychain.db
|
|||
KEYCHAIN_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
KEYCHAIN_COOKIE_SECURE=false
|
||||
KEYCHAIN_SESSION_TTL_SECONDS=1209600
|
||||
KEYCHAIN_EXTENSION_ID=
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ KEYCHAIN_ALLOWED_ORIGINS=http://localhost:5173 \
|
|||
npm run dev
|
||||
```
|
||||
|
||||
По умолчанию web-приложение запускается в безопасном `demo mode`, чтобы можно было посмотреть UI без API. Для реальных регистраций выставьте `VITE_DEMO_MODE=false` в `apps/web/.env.local`. Demo mode использует только синтетические записи, не реальные пароли.
|
||||
По умолчанию web-приложение запускается в безопасном `demo mode`, чтобы можно было посмотреть UI без API. Для реальных регистраций выставьте `VITE_DEMO_MODE=false` в `apps/web/.env.local`. Demo mode использует только синтетические записи, не реальные пароли. Production deployment через PostgreSQL + Docker Compose описан в [`deploy/README.md`](deploy/README.md).
|
||||
|
||||
Сборка:
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from pydantic import BaseModel, Field, field_validator
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DB_PATH = Path(os.getenv("KEYCHAIN_DB_PATH", str(ROOT / "data" / "keychain.db")))
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
DATABASE_URL = os.getenv("KEYCHAIN_DATABASE_URL", "").strip()
|
||||
SESSION_TTL = int(os.getenv("KEYCHAIN_SESSION_TTL_SECONDS", "1209600"))
|
||||
COOKIE_SECURE = os.getenv("KEYCHAIN_COOKIE_SECURE", "false").lower() == "true"
|
||||
COOKIE_SAMESITE = "lax"
|
||||
|
|
@ -38,6 +39,14 @@ configured_origins = [
|
|||
).split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
configured_extension_id = os.getenv("KEYCHAIN_EXTENSION_ID", "").strip()
|
||||
extension_origin_regex = (
|
||||
rf"^chrome-extension://{re.escape(configured_extension_id)}$" if configured_extension_id else None
|
||||
)
|
||||
|
||||
if DATABASE_URL:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
password_hasher = PasswordHasher()
|
||||
email_pattern = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||||
|
|
@ -57,24 +66,36 @@ def hash_token(token: str) -> str:
|
|||
|
||||
|
||||
@contextmanager
|
||||
def db() -> Iterator[sqlite3.Connection]:
|
||||
connection = sqlite3.connect(DB_PATH, timeout=10)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
def db() -> Iterator[Any]:
|
||||
if DATABASE_URL:
|
||||
connection = psycopg.connect(DATABASE_URL, row_factory=dict_row)
|
||||
else:
|
||||
connection = sqlite3.connect(DB_PATH, timeout=10)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys = ON")
|
||||
try:
|
||||
yield connection
|
||||
connection.commit()
|
||||
except BaseException:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def execute(connection: Any, query: str, params: tuple[Any, ...] = ()) -> Any:
|
||||
"""Keep the small dev SQLite adapter and production psycopg adapter aligned."""
|
||||
if DATABASE_URL:
|
||||
query = query.replace("?", "%s")
|
||||
return connection.execute(query, params)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
with db() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
schema = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
|
|
@ -96,8 +117,13 @@ def init_db() -> None:
|
|||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_token_idx ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
|
||||
"""
|
||||
)
|
||||
"""
|
||||
if DATABASE_URL:
|
||||
for statement in schema.split(";"):
|
||||
if statement.strip():
|
||||
execute(connection, statement)
|
||||
else:
|
||||
connection.executescript(schema)
|
||||
|
||||
|
||||
init_db()
|
||||
|
|
@ -137,7 +163,7 @@ class VaultPayload(BaseModel):
|
|||
return value
|
||||
|
||||
|
||||
def public_user(row: sqlite3.Row) -> dict[str, str]:
|
||||
def public_user(row: Any) -> dict[str, str]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"email": row["email"],
|
||||
|
|
@ -162,7 +188,7 @@ def issue_session(response: Response, user_id: str) -> str:
|
|||
now = utc_now()
|
||||
expires = now + timedelta(seconds=SESSION_TTL)
|
||||
with db() as connection:
|
||||
connection.execute(
|
||||
execute(connection,
|
||||
"INSERT INTO sessions (id, user_id, token_hash, csrf_hash, created_at, expires_at, last_seen_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
|
|
@ -202,15 +228,15 @@ def current_session(request: Request) -> sqlite3.Row:
|
|||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
now = utc_now()
|
||||
with db() as connection:
|
||||
session = connection.execute(
|
||||
session = execute(connection,
|
||||
"SELECT * FROM sessions WHERE token_hash = ?", (hash_token(raw_token),)
|
||||
).fetchone()
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="Session is invalid")
|
||||
if datetime.fromisoformat(session["expires_at"]) <= now:
|
||||
connection.execute("DELETE FROM sessions WHERE id = ?", (session["id"],))
|
||||
execute(connection, "DELETE FROM sessions WHERE id = ?", (session["id"],))
|
||||
raise HTTPException(status_code=401, detail="Session has expired")
|
||||
connection.execute(
|
||||
execute(connection,
|
||||
"UPDATE sessions SET last_seen_at = ? WHERE id = ?", (now.isoformat(), session["id"])
|
||||
)
|
||||
return session
|
||||
|
|
@ -225,9 +251,9 @@ def require_csrf(request: Request, session: sqlite3.Row) -> None:
|
|||
raise HTTPException(status_code=403, detail="CSRF token is invalid")
|
||||
|
||||
|
||||
def user_for_session(session: sqlite3.Row) -> sqlite3.Row:
|
||||
def user_for_session(session: Any) -> Any:
|
||||
with db() as connection:
|
||||
user = connection.execute("SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||
user = execute(connection, "SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Account no longer exists")
|
||||
return user
|
||||
|
|
@ -237,7 +263,7 @@ app = FastAPI(title="Keychain API", version="0.1.0")
|
|||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=configured_origins,
|
||||
allow_origin_regex=r"^chrome-extension://[a-zA-Z0-9]+$",
|
||||
allow_origin_regex=extension_origin_regex,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-CSRF-Token"],
|
||||
|
|
@ -256,7 +282,12 @@ async def security_headers(request: Request, call_next):
|
|||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
try:
|
||||
with db() as connection:
|
||||
execute(connection, "SELECT 1").fetchone()
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=503, detail="Database is not ready") from error
|
||||
return {"status": "ok", "database": "ready"}
|
||||
|
||||
|
||||
@app.post("/api/auth/register", status_code=status.HTTP_201_CREATED)
|
||||
|
|
@ -267,21 +298,22 @@ def register(payload: AuthPayload, request: Request, response: Response) -> dict
|
|||
now = iso_now()
|
||||
try:
|
||||
with db() as connection:
|
||||
connection.execute(
|
||||
execute(connection,
|
||||
"INSERT INTO users (id, email, display_name, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(user_id, payload.email, display_name, password_hasher.hash(payload.password), now),
|
||||
)
|
||||
connection.execute(
|
||||
execute(connection,
|
||||
"INSERT INTO vaults (user_id, envelope, revision, updated_at) VALUES (?, ?, 0, ?)",
|
||||
(user_id, None, now),
|
||||
)
|
||||
except sqlite3.IntegrityError as error:
|
||||
if "email" in str(error).lower():
|
||||
except Exception as error:
|
||||
error_text = str(error).lower()
|
||||
if "email" in error_text or "unique" in error_text or "duplicate" in error_text:
|
||||
raise HTTPException(status_code=409, detail="An account with this email already exists") from error
|
||||
raise
|
||||
csrf_token = issue_session(response, user_id)
|
||||
with db() as connection:
|
||||
user = connection.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
user = execute(connection, "SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
return {"user": public_user(user), "csrf_token": csrf_token}
|
||||
|
||||
|
||||
|
|
@ -289,7 +321,7 @@ def register(payload: AuthPayload, request: Request, response: Response) -> dict
|
|||
def login(payload: AuthPayload, request: Request, response: Response) -> dict[str, Any]:
|
||||
enforce_auth_rate_limit(request)
|
||||
with db() as connection:
|
||||
user = connection.execute("SELECT * FROM users WHERE email = ?", (payload.email,)).fetchone()
|
||||
user = execute(connection, "SELECT * FROM users WHERE email = ?", (payload.email,)).fetchone()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Email or password is incorrect")
|
||||
try:
|
||||
|
|
@ -298,7 +330,7 @@ def login(payload: AuthPayload, request: Request, response: Response) -> dict[st
|
|||
raise HTTPException(status_code=401, detail="Email or password is incorrect") from error
|
||||
if password_hasher.check_needs_rehash(user["password_hash"]):
|
||||
with db() as connection:
|
||||
connection.execute(
|
||||
execute(connection,
|
||||
"UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(password_hasher.hash(payload.password), user["id"]),
|
||||
)
|
||||
|
|
@ -315,7 +347,7 @@ def me(session: sqlite3.Row = Depends(current_session)) -> dict[str, Any]:
|
|||
def logout(request: Request, response: Response, session: sqlite3.Row = Depends(current_session)) -> dict[str, str]:
|
||||
require_csrf(request, session)
|
||||
with db() as connection:
|
||||
connection.execute("DELETE FROM sessions WHERE id = ?", (session["id"],))
|
||||
execute(connection, "DELETE FROM sessions WHERE id = ?", (session["id"],))
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
response.delete_cookie(CSRF_COOKIE, path="/")
|
||||
return {"status": "signed_out"}
|
||||
|
|
@ -325,7 +357,7 @@ def logout(request: Request, response: Response, session: sqlite3.Row = Depends(
|
|||
def get_vault(session: sqlite3.Row = Depends(current_session)) -> dict[str, Any]:
|
||||
user = user_for_session(session)
|
||||
with db() as connection:
|
||||
vault = connection.execute("SELECT * FROM vaults WHERE user_id = ?", (user["id"],)).fetchone()
|
||||
vault = execute(connection, "SELECT * FROM vaults WHERE user_id = ?", (user["id"],)).fetchone()
|
||||
if vault is None:
|
||||
raise HTTPException(status_code=404, detail="Vault not found")
|
||||
return {
|
||||
|
|
@ -343,7 +375,7 @@ def put_vault(
|
|||
) -> dict[str, Any]:
|
||||
require_csrf(request, session)
|
||||
with db() as connection:
|
||||
vault = connection.execute(
|
||||
vault = execute(connection,
|
||||
"SELECT revision FROM vaults WHERE user_id = ?", (session["user_id"],)
|
||||
).fetchone()
|
||||
if vault is None:
|
||||
|
|
@ -352,7 +384,7 @@ def put_vault(
|
|||
raise HTTPException(status_code=409, detail="Vault changed on another device; reload before saving")
|
||||
new_revision = payload.revision + 1
|
||||
updated_at = iso_now()
|
||||
connection.execute(
|
||||
updated = execute(connection,
|
||||
"UPDATE vaults SET envelope = ?, revision = ?, updated_at = ? WHERE user_id = ?",
|
||||
(
|
||||
json.dumps(payload.envelope, separators=(",", ":"), ensure_ascii=False),
|
||||
|
|
@ -361,4 +393,6 @@ def put_vault(
|
|||
session["user_id"],
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise HTTPException(status_code=409, detail="Vault changed on another device; reload before saving")
|
||||
return {"revision": new_revision, "updated_at": updated_at}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
fastapi>=0.115,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
argon2-cffi>=23.1,<26.0
|
||||
psycopg[binary]>=3.2,<4.0
|
||||
|
|
|
|||
2
apps/web/.env.production
Normal file
2
apps/web/.env.production
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
VITE_API_URL=/api
|
||||
VITE_DEMO_MODE=false
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@500;600;700;800&display=swap");
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--page: #f5f7fb;
|
||||
|
|
|
|||
22
deploy/Caddyfile.keychain
Normal file
22
deploy/Caddyfile.keychain
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
keychain.agent.snw.su {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
||||
X-Content-Type-Options nosniff
|
||||
X-Frame-Options DENY
|
||||
Referrer-Policy strict-origin-when-cross-origin
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
}
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8799
|
||||
}
|
||||
|
||||
handle {
|
||||
root * /srv/keychain/web
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
28
deploy/README.md
Normal file
28
deploy/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Production deployment
|
||||
|
||||
The production shape is Docker Compose (PostgreSQL + FastAPI) behind the host Caddy instance. Caddy owns HTTPS and serves the Vue build from `/srv/keychain/web`; the API is only published on `127.0.0.1:8799`.
|
||||
|
||||
The deployment secret file lives outside Git at `/etc/keychain/keychain.env` with mode `0600`. It must contain a random URL-safe `KEYCHAIN_DB_PASSWORD` and may contain `KEYCHAIN_EXTENSION_ID` after the unpacked extension receives its stable ID.
|
||||
|
||||
```sh
|
||||
install -d -m 0750 /etc/keychain
|
||||
openssl rand -hex 32
|
||||
install -m 0600 /dev/null /etc/keychain/keychain.env
|
||||
# Put KEYCHAIN_DB_PASSWORD=<generated value> in the file.
|
||||
|
||||
npm ci
|
||||
VITE_API_URL=/api VITE_DEMO_MODE=false npm run build --workspace=@keychain/web
|
||||
install -d -m 0755 /srv/keychain/web
|
||||
cp -a apps/web/dist/. /srv/keychain/web/
|
||||
|
||||
docker compose --env-file /etc/keychain/keychain.env -f deploy/docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
Append `deploy/Caddyfile.keychain` as a new block to `/etc/caddy/Caddyfile`, validate, then restart Caddy. Existing Caddy blocks must remain unchanged. Verify:
|
||||
|
||||
```sh
|
||||
curl -fsS https://keychain.agent.snw.su/api/health
|
||||
docker compose --env-file /etc/keychain/keychain.env -f deploy/docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
For rollback, restore the previous `/srv/keychain/web` contents and redeploy the previous Git commit. Do not remove the named `keychain-postgres` volume.
|
||||
44
deploy/docker-compose.prod.yml
Normal file
44
deploy/docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: keychain
|
||||
POSTGRES_USER: keychain_app
|
||||
POSTGRES_PASSWORD: ${KEYCHAIN_DB_PASSWORD:?KEYCHAIN_DB_PASSWORD is required}
|
||||
volumes:
|
||||
- keychain-postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U keychain_app -d keychain"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ../apps/api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
KEYCHAIN_ENV: production
|
||||
KEYCHAIN_DATABASE_URL: postgresql://keychain_app:${KEYCHAIN_DB_PASSWORD}@postgres:5432/keychain
|
||||
KEYCHAIN_ALLOWED_ORIGINS: https://keychain.agent.snw.su
|
||||
KEYCHAIN_EXTENSION_ID: ${KEYCHAIN_EXTENSION_ID:-}
|
||||
KEYCHAIN_COOKIE_SECURE: "true"
|
||||
KEYCHAIN_SESSION_TTL_SECONDS: "1209600"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:8799:8000"
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
keychain-postgres:
|
||||
Loading…
Add table
Reference in a new issue