ops: prepare production deployment

This commit is contained in:
Keychain Builder 2026-09-09 21:08:37 +00:00
parent a14a5d2471
commit 0ca75d5eff
9 changed files with 162 additions and 32 deletions

View file

@ -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=

View file

@ -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).
Сборка:

View file

@ -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}

View file

@ -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
View file

@ -0,0 +1,2 @@
VITE_API_URL=/api
VITE_DEMO_MODE=false

View file

@ -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
View 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
View 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.

View 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: