# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers # every operator surface under /_gm (the backend-rendered admin console and the # Grafana subpath); the game SPA (/app/, /telegram/, /vk/) and the Connect edge go to # the gateway; the catch-all — notably the public landing at / — goes to the # static landing container, so stray traffic never reaches the Go edge. # Mirrors ../galaxy-game's /_gm model. # # CADDY_SITE_ADDRESS is ":80" in the test contour (the host caddy terminates TLS # and forwards); set it to a domain in prod so this caddy does its own # ACME and the contour is self-contained. { admin off # Trust X-Forwarded-For from private-range upstreams so the real client IP survives # (chat moderation + per-IP rate limiting in the gateway). Test contour: the host caddy # (a private IP) is trusted, so its forwarded client IP is preserved. Prod (no host caddy): # clients connect from public IPs, which are NOT trusted, so Caddy uses the real peer — # the same config is correct (and spoof-safe) in both contours. servers { trusted_proxies static private_ranges } } {$CADDY_SITE_ADDRESS::80} { # HTTP/3 is advertised by default whenever this caddy terminates TLS (prod: # CADDY_SITE_ADDRESS is the domain). But UDP/443 is never reachable — the prod # compose maps only "443:443" (TCP) and ufw opens 443/tcp — so a client that cached # the `Alt-Svc: h3` advert (sticky for ma=2592000s) stalls on the dead QUIC path # before falling back to h2, which surfaced as the Telegram Mini App intermittently # hanging on load. `Alt-Svc: clear` actively drops any cached alternative and pins # clients to h2/h1; it is applied site-wide so every route is covered. In the test # contour this caddy serves plain :80 (no h3 to advertise) and the host caddy # re-stamps its own Alt-Svc, so the live test fix lives in the host caddy — here it # is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md. header Alt-Svc clear # Maintenance gate. While the deploy holds the flag file /srv/maint/on (touched by # prod-deploy.sh around a rolling swap, removed on the script's exit), serve a static # 503 "works in progress" page instead of proxying to a mid-recreate upstream — # a graceful signal rather than raw 502s. Operator surfaces (/_gm) are excluded so # Grafana stays reachable during the window. Caddy stat()s the flag per request, so # toggling it needs no reload; the page + flag live in the caddy config dir bind-mounted # read-only at /srv/maint (docker-compose.yml). The test contour never sets the flag # (only prod-deploy.sh does), so this is inert there. @maintenance { not path /_gm /_gm/* file { root /srv/maint try_files on } } handle @maintenance { error 503 } handle_errors { @maint503 expression {err.status_code} == 503 handle @maint503 { root * /srv/maint rewrite * /maintenance.html header Retry-After 120 # Distinctive maintenance marker so the SPA can tell a planned window apart from # a transient upstream 503 and show its own dimmed overlay (an in-session user # never sees this static page — it only renders on a fresh load). The header # rides every gated response, incl. the Connect/gRPC edge the app polls. header X-Scrabble-Maintenance 1 file_server } } # Operator surfaces under /_gm: a single shared Basic-Auth, then route. @gm path /_gm /_gm/* handle @gm { basic_auth { {$GM_BASICAUTH_USER:gm} {$GM_BASICAUTH_HASH} } # Grafana serves from this sub-path (GF_SERVER_SERVE_FROM_SUB_PATH=true), so # the prefix is forwarded intact, not stripped. handle /_gm/grafana* { reverse_proxy grafana:3000 } # Everything else under /_gm is the backend-rendered admin console. handle { reverse_proxy backend:8080 } } # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /pay/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot } } # Honeypot decoy paths: classic vulnerability-scanner bait no real client ever # requests. Route them to the gateway tagged with X-Scrabble-Honeypot — the set # replaces any client-supplied value — so it logs the scanner hit and (in prod) # bans the source IP. (A delete + set in one block would not work: Caddy applies # header_up deletions after sets, which would strip the tag we just set; the real # endpoints instead strip the header in the @gateway block above.) Keep this list # disjoint from every legitimate landing/app path. @honeypot path /.env /.git /.git/* /.aws/* /wp-login.php /wp-admin /wp-admin/* /phpmyadmin /phpmyadmin/* handle @honeypot { reverse_proxy gateway:8081 { header_up X-Scrabble-Honeypot 1 } } # The public legal pages are rendered by the render sidecar: the offer (with the live catalog # price list from the backend spliced into ui/legal/offer_ru.md), the privacy policy and the EULA # (both static markdown). Only these paths are exposed here — the sidecar's /render stays off this # allow-list, internal-only. Kept disjoint from the landing/app paths so the catch-all below never # shadows them. @legal path /offer /offer/* /privacy /privacy/* /eula /eula/* handle @legal { reverse_proxy renderer:8090 } # Everything else — the public landing at / and any stray path — is static. handle { reverse_proxy landing:80 } }