- GET /health/ready (Electrum connected, header < 1800s, scheduler heartbeat < 30s) is the container healthcheck; the scheduler beats on every tick and draw-wait iteration - GET /admin/electrum and an Electrum indicator in /admin (server, header age, last rotation) - GET /admin/audit-log: before_id, event_type, round_id, user_id, from, to; the panel gets filters - registration writes user_registered in its transaction - the app's own logs also go to stdout; uvicorn trusts X-Forwarded-For from Caddy's fixed address only, so the access log shows the client's IP Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
225 lines
9.7 KiB
Python
225 lines
9.7 KiB
Python
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request, status
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
|
from app.logging_config import setup_logging
|
|
|
|
setup_logging()
|
|
|
|
import app.bets.confirmation # noqa: F401 (registers the "bet" confirmation handler)
|
|
import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmation handler)
|
|
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
|
|
from app.api.routes.admin import router as admin_router
|
|
from app.api.routes.bets import router as bets_router
|
|
from app.api.routes.bug_reports import router as bug_reports_router
|
|
from app.api.routes.qr import router as qr_router
|
|
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.api.errors import ApiError
|
|
from app.config import settings, validate_quorum_servers, validate_runtime_secrets
|
|
from app.db.base import AsyncSessionLocal
|
|
from app.deposits.reconcile import DepositReconciler
|
|
from app.electrum.client import ElectrumClient, ElectrumEndpoint, parse_endpoints
|
|
from app.electrum.listener import ElectrumListener
|
|
from app.rounds.scheduler import RoundScheduler
|
|
from app.rounds.pause import pause_by_operator
|
|
from app.tx.broadcast import RbfBumper
|
|
from app.tx.confirmation import ConfirmationPoller
|
|
from app.tx.locks import UserLocks
|
|
from app.tx.reconcile import PendingTransactionReconciler
|
|
from app.wallet.verify import verify_master_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _make_electrum_client(endpoint: ElectrumEndpoint) -> ElectrumClient:
|
|
return ElectrumClient(endpoint.host, endpoint.port, endpoint.use_ssl, endpoint.pin)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Refuses to serve rather than starting up half-configured (B-15).
|
|
validate_runtime_secrets()
|
|
|
|
endpoints = parse_endpoints(
|
|
settings.electrum_host,
|
|
settings.electrum_port,
|
|
settings.electrum_use_ssl,
|
|
settings.electrum_fallback_servers,
|
|
settings.electrum_cert_pin,
|
|
)
|
|
validate_quorum_servers(endpoints) # B-84: refuses to serve without a usable quorum
|
|
verify_master_key() # B-89: refuses to serve with a key that did not produce the stored addresses
|
|
if settings.start_paused: # B-90 (OPS-27): before the scheduler exists, so no round can open first
|
|
async with AsyncSessionLocal() as session:
|
|
await pause_by_operator(session, "START_PAUSED")
|
|
await session.commit()
|
|
logger.warning("START_PAUSED=true: the lottery starts paused (resume it from /admin)")
|
|
logger.info("Electrum endpoints (in rotation order): %s", ", ".join(str(e) for e in endpoints))
|
|
|
|
listener = ElectrumListener(
|
|
_make_electrum_client, AsyncSessionLocal, endpoints, allow_no_quorum=settings.allow_no_quorum
|
|
)
|
|
app.state.electrum_listener = listener
|
|
app.state.user_locks = UserLocks()
|
|
|
|
scheduler = RoundScheduler(AsyncSessionLocal, listener)
|
|
app.state.scheduler = scheduler # B-92: /health/ready reads its heartbeat
|
|
poller = ConfirmationPoller(AsyncSessionLocal, lambda: listener.client, network=listener)
|
|
bumper = RbfBumper(AsyncSessionLocal, lambda: listener.client, network=listener)
|
|
# Resolves in-flight transactions against the chain — the piece that lets the
|
|
# system recover on its own from a broadcast that never confirmed (B-04/B-08).
|
|
reconciler = PendingTransactionReconciler(AsyncSessionLocal, lambda: listener.client, network=listener)
|
|
# Periodic safety net for deposit crediting/external-spend detection,
|
|
# independent of scripthash-change notifications — catches a subscription
|
|
# silently lost on an otherwise healthy connection (B-30).
|
|
deposit_reconciler = DepositReconciler(AsyncSessionLocal, listener)
|
|
|
|
tasks = [
|
|
asyncio.create_task(listener.run()),
|
|
asyncio.create_task(scheduler.run()),
|
|
asyncio.create_task(poller.run()),
|
|
asyncio.create_task(bumper.run()),
|
|
asyncio.create_task(reconciler.run()),
|
|
asyncio.create_task(deposit_reconciler.run()),
|
|
]
|
|
try:
|
|
yield
|
|
finally:
|
|
for task in tasks:
|
|
task.cancel()
|
|
if listener.client is not None:
|
|
await listener.client.close()
|
|
|
|
|
|
# Swagger/ReDoc/the raw OpenAPI JSON enumerate the entire API surface, admin
|
|
# endpoints included, to anyone who requests them (B-42) — disabled unless
|
|
# ENABLE_API_DOCS is explicitly set, which should only happen in development.
|
|
app = FastAPI(
|
|
title="PLM Lottery",
|
|
lifespan=lifespan,
|
|
docs_url="/docs" if settings.enable_api_docs else None,
|
|
redoc_url="/redoc" if settings.enable_api_docs else None,
|
|
openapi_url="/openapi.json" if settings.enable_api_docs else None,
|
|
)
|
|
app.include_router(auth_router)
|
|
app.include_router(users_router)
|
|
app.include_router(bets_router)
|
|
app.include_router(withdrawals_router)
|
|
app.include_router(admin_router)
|
|
app.include_router(bug_reports_router)
|
|
app.include_router(qr_router)
|
|
app.include_router(rounds_router)
|
|
|
|
|
|
# B-94 (API-15, API-16): no error answers with a bare-string or list `detail`; the framework's
|
|
# own 401/404/405 and request-validation failures take the structured shape too.
|
|
_FRAMEWORK_CODES = {401: "not_authenticated", 404: "not_found", 405: "method_not_allowed"}
|
|
|
|
|
|
@app.exception_handler(StarletteHTTPException)
|
|
async def structured_http_exception(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
if isinstance(exc.detail, dict) and "code" in exc.detail:
|
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers)
|
|
code = _FRAMEWORK_CODES.get(exc.status_code, f"http_{exc.status_code}")
|
|
detail = ApiError(code, str(exc.detail)).as_detail()
|
|
return JSONResponse(status_code=exc.status_code, content={"detail": detail}, headers=exc.headers)
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def structured_validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
"""`input` is left out on purpose: an over-long password must not come back in the response."""
|
|
errors = [{"loc": list(e["loc"]), "msg": e["msg"], "type": e["type"]} for e in exc.errors()]
|
|
detail = ApiError("invalid_request", "invalid request", errors=errors).as_detail()
|
|
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content={"detail": detail})
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def log_unhandled_exception(request: Request, exc: Exception) -> JSONResponse:
|
|
"""Answers with the same structured `detail` shape as every deliberate failure
|
|
(app/api/errors.py) so clients never have to special-case unexpected errors.
|
|
The exception itself stays in logs/app.log only — never in the response body."""
|
|
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"detail": ApiError("internal_error", "internal server error").as_detail()},
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
# B-92 (API-46, ELC-29, ARC-61): the header age is generous on purpose — with 120 s blocks, 360 s
|
|
# of silence happens by chance about 5% of the time (e^-3), and a healthcheck must not flap on that.
|
|
READY_MAX_HEADER_AGE_SECONDS = 1800
|
|
READY_MAX_SCHEDULER_HEARTBEAT_SECONDS = 30
|
|
|
|
|
|
@app.get("/health/ready", include_in_schema=False)
|
|
async def health_ready() -> JSONResponse:
|
|
listener = getattr(app.state, "electrum_listener", None)
|
|
scheduler = getattr(app.state, "scheduler", None)
|
|
header_age = listener.last_header_age_seconds() if listener is not None else None
|
|
heartbeat_age = scheduler.heartbeat_age_seconds() if scheduler is not None else None
|
|
checks = {
|
|
"electrum_connected": listener is not None and listener.client is not None,
|
|
"last_header_age_seconds": header_age,
|
|
"scheduler_heartbeat_age_seconds": heartbeat_age,
|
|
}
|
|
ready = (
|
|
checks["electrum_connected"]
|
|
and header_age is not None
|
|
and header_age < READY_MAX_HEADER_AGE_SECONDS
|
|
and heartbeat_age is not None
|
|
and heartbeat_age < READY_MAX_SCHEDULER_HEARTBEAT_SECONDS
|
|
)
|
|
return JSONResponse(
|
|
status_code=200 if ready else 503, content={"status": "ready" if ready else "not_ready", "checks": checks}
|
|
)
|
|
|
|
|
|
_NO_STORE_HEADERS = {"Cache-Control": "no-store"}
|
|
|
|
|
|
@app.get("/", include_in_schema=False)
|
|
async def index_page() -> FileResponse:
|
|
return FileResponse("app/static/index.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
@app.get("/admin", include_in_schema=False)
|
|
async def admin_panel() -> FileResponse:
|
|
return FileResponse("app/static/admin.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
@app.get("/guida", include_in_schema=False)
|
|
async def user_guide() -> FileResponse:
|
|
return FileResponse("app/static/guida.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
@app.get("/report-bug", include_in_schema=False)
|
|
async def report_bug_page() -> FileResponse:
|
|
return FileResponse("app/static/report-bug.html", headers=_NO_STORE_HEADERS)
|
|
|
|
|
|
class NoCacheStaticFiles(StaticFiles):
|
|
"""B-94 (API-09): the browser may keep a copy but revalidates it on every use, so after an
|
|
update it does not run an old app.js against the new API."""
|
|
|
|
async def get_response(self, path, scope):
|
|
response = await super().get_response(path, scope)
|
|
response.headers["Cache-Control"] = "no-cache"
|
|
return response
|
|
|
|
|
|
app.mount("/", NoCacheStaticFiles(directory="app/static", html=True), name="static")
|