"""FastAPI application of the Wizard game server. Routes (the reverse proxy strips its path prefix, e.g. ``/wizard/api``): - ``GET /health`` health check - ``WS /ws`` web clients (see ``app.protocol``) - ``WS /internal/ai`` AI service (see ``app.ai_bridge``); only accepted from direct connections on the internal network and protected by ``AI_SHARED_SECRET`` """ from __future__ import annotations import asyncio import contextlib import logging import secrets from collections.abc import AsyncIterator from fastapi import FastAPI, WebSocket from .config import Settings, settings from .service import WizardService logger = logging.getLogger(__name__) def create_app(config: Settings = settings) -> FastAPI: service = WizardService(config) @contextlib.asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: if not config.ai_shared_secret: logger.warning("AI_SHARED_SECRET is not set, /internal/ai is unauthenticated") tasks = [ asyncio.create_task(service.cleanup_loop()), asyncio.create_task(service.load.run()), ] try: yield finally: for task in tasks: task.cancel() app = FastAPI(title="Wizard game server", lifespan=lifespan, docs_url=None, redoc_url=None) app.state.service = service @app.get("/health") async def health() -> dict: return service.health() @app.websocket("/ws") async def client_socket(websocket: WebSocket) -> None: await service.handle_client(websocket) @app.websocket("/internal/ai") async def ai_socket(websocket: WebSocket) -> None: if _via_proxy(websocket) or not _ai_authorized(websocket, config.ai_shared_secret): await websocket.close(code=1008) return await service.handle_ai(websocket) return app def _via_proxy(websocket: WebSocket) -> bool: # The reverse proxy forwards everything below its prefix, including # /internal/ai. Requests that went through it carry forwarding headers; # the AI service connects directly over the internal network. return any(name in websocket.headers for name in ("x-forwarded-for", "forwarded", "via")) def _ai_authorized(websocket: WebSocket, secret: str) -> bool: if not secret: return True header = websocket.headers.get("authorization", "") token = header.removeprefix("Bearer ").strip() if header.startswith("Bearer ") else "" return secrets.compare_digest(token.encode(), secret.encode()) logging.basicConfig( level=settings.log_level.upper(), format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) app = create_app()