Refuse to start half-configured, and keep failures machine-readable
Startup validation (B-15). An empty JWT_SECRET makes PyJWT raise InvalidKeyError
on every login and an empty XPRV_ENCRYPTION_KEY makes Fernet fail on the first
key derivation — either way the container came up looking healthy and broke the
moment a real user touched it. validate_runtime_secrets() reports every problem
at once and is called from the lifespan (wired in the next commit).
Deviation from the plan in BUGS.md, which proposed a Pydantic field_validator:
Settings is constructed at import time by every module that reads config,
including the test suite, which has no .env and no business holding real secrets
— a validator there would fail a fresh clone at collection. At startup the
guarantee that matters is unchanged (the server won't serve traffic
half-configured) without coupling imports to a gitignored file. An empty
ADMIN_TOKEN is deliberately non-fatal: require_admin already denies everything,
so the effect is a locked panel, not an open one.
Unhandled errors answer the documented shape (B-24). The catch-all returned a
bare-string `detail` while app/api/errors.py documents
{"code", "message", "params"}, leaving clients to special-case exactly the
responses they understand least. It now returns internal_error in that shape,
with the exception text staying in logs/app.log and out of the response body.
GET /guida no longer crashes (B-16). It reads docs/guida-utente.md, which the
Dockerfile doesn't ship, so in every real deployment that navbar link was a 500 —
confirmed in the deployed log, which holds two of them from earlier today ending
in "RuntimeError: File at path docs/guida-utente.md does not exist." It now
checks the file and answers a structured 404 (guide_unavailable) with an error
logged. Shipping docs/ in the image was written and then reverted on request: the
guide is being reworked first, so /guida answers 404 in Docker for now, which is
an accepted state rather than an oversight.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Minimum JWT signing key length. HS256 keys shorter than the hash output weaken
|
||||
# the MAC, and PyJWT warns about it — enforced here so it fails at startup rather
|
||||
# than being shipped by accident.
|
||||
MIN_JWT_SECRET_LENGTH = 32
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
@@ -25,3 +30,40 @@ class Settings(BaseSettings):
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""A misconfiguration serious enough that the app must refuse to serve."""
|
||||
|
||||
|
||||
def validate_runtime_secrets(config: Settings | None = None) -> None:
|
||||
"""Fail fast on secrets that would otherwise only break at first use: an empty
|
||||
jwt_secret makes PyJWT raise InvalidKeyError on every login, and an empty
|
||||
xprv_encryption_key makes Fernet fail on the first key derivation. Either way
|
||||
the container comes up looking healthy and breaks the moment a real user
|
||||
touches it.
|
||||
|
||||
Called from the app's lifespan (app/main.py) rather than as a Settings
|
||||
field_validator on purpose: Settings is constructed at import time by every
|
||||
module that reads config, including the test suite, which has no .env and no
|
||||
business holding real secrets. At startup the guarantee still holds where it
|
||||
matters — the server refuses to serve half-configured — without coupling every
|
||||
import to a gitignored file.
|
||||
|
||||
ADMIN_TOKEN is deliberately not fatal: require_admin already denies every
|
||||
request when it's empty, so the effect is a locked admin panel, not an open one.
|
||||
"""
|
||||
config = config or settings
|
||||
problems = []
|
||||
if len(config.jwt_secret) < MIN_JWT_SECRET_LENGTH:
|
||||
problems.append(
|
||||
f"JWT_SECRET must be at least {MIN_JWT_SECRET_LENGTH} characters "
|
||||
'(generate: python -c "import secrets; print(secrets.token_urlsafe(32))")'
|
||||
)
|
||||
if not config.xprv_encryption_key.strip():
|
||||
problems.append(
|
||||
"XPRV_ENCRYPTION_KEY must be set (generate: python -c "
|
||||
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")'
|
||||
)
|
||||
if problems:
|
||||
raise ConfigError("invalid configuration in .env: " + "; ".join(problems))
|
||||
|
||||
+20
-5
@@ -1,8 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -20,7 +21,8 @@ from app.api.routes.rounds import router as rounds_router
|
||||
from app.api.routes.users import router as users_router
|
||||
from app.api.routes.withdrawals import router as withdrawals_router
|
||||
from app.auth.routes import router as auth_router
|
||||
from app.config import settings
|
||||
from app.api.errors import ApiError, http_error
|
||||
from app.config import settings, validate_runtime_secrets
|
||||
from app.db.base import AsyncSessionLocal
|
||||
from app.electrum.client import ElectrumClient
|
||||
from app.electrum.listener import ElectrumListener
|
||||
@@ -74,7 +76,10 @@ app.include_router(rounds_router)
|
||||
@app.exception_handler(Exception)
|
||||
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
||||
return JSONResponse(status_code=500, content={"detail": "internal server error"})
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": ApiError("internal_error", "internal server error").as_detail()},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -99,8 +104,18 @@ async def admin_panel() -> FileResponse:
|
||||
async def user_guide() -> FileResponse:
|
||||
"""Serves docs/guida-utente.md as plain text so it opens inline in the
|
||||
browser (no markdown rendering — keeps this simple), linked from the
|
||||
navbar's help button in app/static/index.html."""
|
||||
return FileResponse("docs/guida-utente.md", media_type="text/plain; charset=utf-8")
|
||||
navbar's help button in app/static/index.html.
|
||||
|
||||
The Dockerfile must COPY docs/ for this to exist inside the container (it
|
||||
didn't, which made this a guaranteed 500 in every real deployment — B-16).
|
||||
Answers a clean 404 if the file is missing rather than an unhandled error."""
|
||||
if not _USER_GUIDE_PATH.is_file():
|
||||
logger.error("user guide missing at %s (is docs/ shipped in the image?)", _USER_GUIDE_PATH)
|
||||
raise http_error(status.HTTP_404_NOT_FOUND, "guide_unavailable", "user guide not available")
|
||||
return FileResponse(_USER_GUIDE_PATH, media_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory="app/static", html=True), name="static")
|
||||
_USER_GUIDE_PATH = Path("docs/guida-utente.md")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user