From 90176e1cca39411c1f75523eda73ffe50792e4ef Mon Sep 17 00:00:00 2001 From: Marvin Date: Sun, 27 Sep 2026 10:00:29 +0200 Subject: [PATCH] Serve API and website behind Caddy path prefixes Caddy forwards /wizard/api/* and /wizard/website/* with the prefix stripped, so the server received /ws instead of /api/ws and the website connected to /api/ws on the domain root. - Server routes are now /ws and /health (was /api/ws, /api/health) - Website derives the websocket URL from its own location (../api/ws), giving /wizard/api/ws in production and /api/ws in development - Vite dev proxy strips /api like the production proxy - nginx back to the plain SPA config since Caddy strips the prefix - /internal/ai rejects requests that came through the reverse proxy, in addition to the shared secret - Docs updated for the prefixed deployment Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 +- README.md | 11 +++++++---- wizard_server/Dockerfile | 2 +- wizard_server/README.md | 3 ++- wizard_server/app/main.py | 23 +++++++++++++++-------- wizard_server/app/protocol.py | 2 +- wizard_server/tests/test_server.py | 17 +++++++++++++---- wizard_website/.env.example | 4 ++-- wizard_website/Dockerfile | 4 ++-- wizard_website/README.md | 7 ++++--- wizard_website/nginx.conf | 13 +++---------- wizard_website/src/api/useWizard.ts | 8 ++++++-- wizard_website/vite.config.ts | 4 +++- 13 files changed, 60 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index 9bf05b9..4358863 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # python3 -c "import secrets; print(secrets.token_urlsafe(32))" AI_SHARED_SECRET=change-me -# Optional: comma separated list of allowed websocket origins, e.g. https://wizard.example.com +# Optional: comma separated list of allowed websocket origins, e.g. https://game.marvinkrausser.com ALLOWED_ORIGINS= # Agent of the AI service: model (falls back to heuristic without checkpoint), heuristic or random diff --git a/README.md b/README.md index 02068e1..85b826f 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ An online version of the card game Wizard (rules: `wizard_instructions/`). | AI player | `wizard_ai/` | Python; RL training skeleton + websocket service | ``` -browser ──wss /api/ws──▶ Caddy ──▶ wizard_server ◀──ws /internal/ai── wizard_ai - ──https /──────▶ Caddy ──▶ wizard_website (network wizard_internal) +browser ──wss /wizard/api/ws──────▶ Caddy ──/ws──▶ wizard_server:8083 ◀──ws /internal/ai── wizard_ai + ──https /wizard/website/──▶ Caddy ──/────▶ wizard_website:8082 (network wizard_internal) ``` ## How it works @@ -43,8 +43,11 @@ docker compose up -d --build - `wizard_server` joins the external network `wizard_api` (port 8083) and `wizard_internal`. - `wizard_ai` joins only `wizard_internal`, created by compose as an internal network. -Add a site block like `Caddyfile.example` to the existing Caddy container: `/api/*` goes to -`wizard_server:8083`, everything else to `wizard_website:8082`. +In the Caddy reverse proxy, `/wizard/website/*` goes to `wizard_website:8082` +and `/wizard/api/*` to `wizard_server:8083`, both with the prefix stripped. The website uses +relative asset paths and derives the API address from its own URL (`../api/ws`), so it works under +any prefix as long as the website and API are siblings. `/internal/ai` refuses requests that come +through the proxy (forwarding headers present), in addition to requiring `AI_SHARED_SECRET`. Trained AI checkpoints go into `wizard_ai/models/latest/` (mounted read-only). Until one exists the AI service uses the built-in heuristic player. diff --git a/wizard_server/Dockerfile b/wizard_server/Dockerfile index 15b5649..5abd4c9 100644 --- a/wizard_server/Dockerfile +++ b/wizard_server/Dockerfile @@ -21,6 +21,6 @@ USER wizard EXPOSE 8083 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8083/api/health', timeout=2)" + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8083/health', timeout=2)" CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8083", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/wizard_server/README.md b/wizard_server/README.md index 3c82a1b..3225567 100644 --- a/wizard_server/README.md +++ b/wizard_server/README.md @@ -3,7 +3,8 @@ FastAPI game server. It manages sessions, lobbies and games, and talks to the AI service. - `game_logic/` — the standalone rules engine (`pip install ./game_logic`, see its README). -- `app/main.py` — routes: `GET /api/health`, `WS /api/ws` (browsers), `WS /internal/ai` (AI service) +- `app/main.py` — routes: `GET /health`, `WS /ws` (browsers), `WS /internal/ai` (AI service, + refused when the request came through the reverse proxy) - `app/protocol.py` — client message schema - `app/service.py` — dispatches client messages, reconnects sessions, cleanup - `app/lobby.py` — lobbies, seats, host actions, running games and AI turns diff --git a/wizard_server/app/main.py b/wizard_server/app/main.py index cf29ce4..8860403 100644 --- a/wizard_server/app/main.py +++ b/wizard_server/app/main.py @@ -1,10 +1,10 @@ """FastAPI application of the Wizard game server. -Routes: -- ``GET /api/health`` health check -- ``WS /api/ws`` web clients (see ``app.protocol``) -- ``WS /internal/ai`` AI service (see ``app.ai_bridge``); not meant to be - exposed by the reverse proxy and protected by ``AI_SHARED_SECRET`` +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 @@ -39,7 +39,7 @@ def create_app(config: Settings = settings) -> FastAPI: app = FastAPI(title="Wizard game server", lifespan=lifespan, docs_url=None, redoc_url=None) app.state.service = service - @app.get("/api/health") + @app.get("/health") async def health() -> dict: return { "status": "ok", @@ -48,13 +48,13 @@ def create_app(config: Settings = settings) -> FastAPI: "ai_available": service.bridge.available, } - @app.websocket("/api/ws") + @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 not _ai_authorized(websocket, config.ai_shared_secret): + if _via_proxy(websocket) or not _ai_authorized(websocket, config.ai_shared_secret): await websocket.close(code=1008) return await service.handle_ai(websocket) @@ -62,6 +62,13 @@ def create_app(config: Settings = settings) -> FastAPI: 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 diff --git a/wizard_server/app/protocol.py b/wizard_server/app/protocol.py index e6f0b88..f322868 100644 --- a/wizard_server/app/protocol.py +++ b/wizard_server/app/protocol.py @@ -1,4 +1,4 @@ -"""Messages exchanged with web clients over the ``/api/ws`` websocket. +"""Messages exchanged with web clients over the ``/ws`` websocket. Client -> server messages are validated with pydantic. Server -> client messages are plain dicts: diff --git a/wizard_server/tests/test_server.py b/wizard_server/tests/test_server.py index 97267b0..6c988c9 100644 --- a/wizard_server/tests/test_server.py +++ b/wizard_server/tests/test_server.py @@ -32,7 +32,7 @@ def is_state(message): def connect(client, token=None): - ws = client.sockets.enter_context(client.websocket_connect("/api/ws")) + ws = client.sockets.enter_context(client.websocket_connect("/ws")) ws.send_json({"type": "hello", "token": token}) welcome = receive_until(ws, lambda m: m["type"] == "welcome") receive_until(ws, is_state) @@ -40,13 +40,13 @@ def connect(client, token=None): def test_health(client): - response = client.get("/api/health") + response = client.get("/health") assert response.status_code == 200 assert response.json()["status"] == "ok" def test_requires_hello(client): - with client.websocket_connect("/api/ws") as ws: + with client.websocket_connect("/ws") as ws: ws.send_json({"type": "create_lobby", "name": "Ann"}) assert ws.receive_json()["type"] == "error" @@ -118,7 +118,7 @@ def test_reconnect_with_token(client): code = receive_until(ws, is_state)["lobby"]["code"] ws.close() - ws2 = client.sockets.enter_context(client.websocket_connect("/api/ws")) + ws2 = client.sockets.enter_context(client.websocket_connect("/ws")) ws2.send_json({"type": "hello", "token": welcome["token"]}) again = receive_until(ws2, lambda m: m["type"] == "welcome") assert again["player_id"] == welcome["player_id"] @@ -153,6 +153,15 @@ def test_ai_endpoint_requires_secret(client): ws.receive_json() +def test_ai_endpoint_rejects_proxied_requests(client): + from starlette.websockets import WebSocketDisconnect + + headers = {"Authorization": f"Bearer {SECRET}", "X-Forwarded-For": "203.0.113.7"} + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/internal/ai", headers=headers) as ws: + ws.receive_json() + + def test_ai_service_decides(client): """A fake AI service answers decide requests with the first legal action.""" requests = [] diff --git a/wizard_website/.env.example b/wizard_website/.env.example index 64d3595..4533f4c 100644 --- a/wizard_website/.env.example +++ b/wizard_website/.env.example @@ -1,3 +1,3 @@ -# Absolute websocket URL of the game server. Leave empty to use /api/ws on the -# same host the website is served from (recommended behind the Caddy proxy). +# Absolute websocket URL of the game server. Leave empty to connect to +# ../api/ws relative to the page URL (recommended). VITE_WS_URL= diff --git a/wizard_website/Dockerfile b/wizard_website/Dockerfile index a202a87..3f9ca23 100644 --- a/wizard_website/Dockerfile +++ b/wizard_website/Dockerfile @@ -6,8 +6,8 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . -# Optional absolute websocket URL of the game server, e.g. wss://api.example.com/api/ws. -# By default the app connects to /api/ws on the host it was loaded from. +# Optional absolute websocket URL of the game server, e.g. wss://game.example.com/wizard/api/ws. +# By default the app connects to ../api/ws relative to the page URL. ARG VITE_WS_URL= ENV VITE_WS_URL=${VITE_WS_URL} RUN npm run build diff --git a/wizard_website/README.md b/wizard_website/README.md index aab2f5f..3028fca 100644 --- a/wizard_website/README.md +++ b/wizard_website/README.md @@ -6,13 +6,14 @@ React + TypeScript frontend (Vite). All component styles are CSS modules ```bash npm install -npm run dev # http://localhost:5173, proxies /api (incl. websocket) to localhost:8000 +npm run dev # http://localhost:5173, proxies /api/* (incl. websocket) to localhost:8000 without the /api prefix npm run build npm run lint ``` -Set `WIZARD_SERVER_URL` to proxy to another server in development, or -`VITE_WS_URL` at build time to connect to an absolute websocket URL. +The app is built with relative asset paths and connects to `../api/ws` relative to its own URL +(`/wizard/website/` → `/wizard/api/ws`). Set `WIZARD_SERVER_URL` to proxy to another server in +development, or `VITE_WS_URL` at build time to use an absolute websocket URL instead. ## Structure diff --git a/wizard_website/nginx.conf b/wizard_website/nginx.conf index fb6fac0..1e075cb 100644 --- a/wizard_website/nginx.conf +++ b/wizard_website/nginx.conf @@ -6,19 +6,12 @@ server { # Hashed build assets can be cached forever. location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; - try_files $uri @strip_prefix; + try_files $uri =404; } + # Single page app: always serve index.html. location / { add_header Cache-Control "no-cache"; - try_files $uri @strip_prefix; - } - - # The reverse proxy may forward requests with their path prefix - # (e.g. /website/assets/app.js). Drop leading segments until a file - # matches; everything else is the single page app. - location @strip_prefix { - rewrite ^/[^/]+(/.+)$ $1 last; - rewrite ^ /index.html last; + try_files $uri /index.html; } } diff --git a/wizard_website/src/api/useWizard.ts b/wizard_website/src/api/useWizard.ts index 3202f8f..a01f66c 100644 --- a/wizard_website/src/api/useWizard.ts +++ b/wizard_website/src/api/useWizard.ts @@ -29,8 +29,12 @@ const CLOSE_REPLACED = 4000 function websocketUrl(): string { const configured = import.meta.env.VITE_WS_URL as string | undefined if (configured) return configured - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - return `${protocol}//${window.location.host}/api/ws` + // The API lives next to the website: /wizard/website/ -> /wizard/api/ws + // (and /api/ws in development). + const url = new URL('../api/ws', window.location.href) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.search = '' + return url.toString() } function readToken(): string | null { diff --git a/wizard_website/vite.config.ts b/wizard_website/vite.config.ts index ed2660d..d25cb13 100644 --- a/wizard_website/vite.config.ts +++ b/wizard_website/vite.config.ts @@ -7,11 +7,13 @@ export default defineConfig({ // Relative asset URLs, so the app works under any path prefix (e.g. /website/). base: './', server: { - // Forward API requests (including the websocket) to a locally running game server. + // Forward API requests (including the websocket) to a locally running game + // server, stripping the prefix like the production reverse proxy does. proxy: { '/api': { target: process.env.WIZARD_SERVER_URL ?? 'http://localhost:8000', ws: true, + rewrite: (path) => path.replace(/^\/api/, ''), }, }, },