Files
scrabble-game/gateway
Ilia Denisov ff55d5de83
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m58s
fix(gateway): forward the real client IP to the backend on every call
The backend recorded 172.19.0.9 — the gateway's own docker connection address — as
the client IP for all users: the account's last-login IP shown in the admin console,
and it never reached the backend access log. The gateway forwarded the client IP as
X-Forwarded-For only on chat/feedback calls; every other backend call (including the
profile fetch that stamps last_login_ip) sent none, so the backend fell back to the
peer address.

Carry the client IP on the request context (WithClientIP, mirroring WithPlatform) and
set it once per request in the Connect edge, so the backend client injects
X-Forwarded-For on every downstream REST call. Also add the resolved client IP to the
backend access log.

Test: WithClientIP rides a non-chat call (Profile) as X-Forwarded-For, and is absent
when no IP is set. Docs updated (ARCHITECTURE gateway↔backend + edge).
2026-07-14 11:23:32 +02:00
..

gateway

The Scrabble platform's only public ingress (module scrabble/gateway). It terminates the client's Connect-RPC + FlatBuffers traffic over HTTP/2 cleartext (h2c), authenticates the originating credential, mints/resolves a thin opaque session, rate-limits, injects X-User-ID when forwarding to the backend over REST/JSON, and bridges the backend's gRPC push stream to each client's in-app live channel. It embeds the static UI build (go:embed, baked in by the gateway image's node stage) and serves a landing page at / and the game SPA at /app/ (web), /telegram/ (the Telegram Mini App) and /vk/ (the VK Mini App) — the single-origin model. The web SPA is an installable PWA: it serves manifest.webmanifest (unhashed from ui/public/, with the .webmanifest MIME type registered in-process — the distroless image has no /etc/mime.types) and the app-shell sw.js (built from ui/src/sw.ts by vite-plugin-pwa, which precaches the shell + assets so an installed PWA cold-launches offline). Hash-named /assets/* are served immutable; the HTML shells are no-cache. It can also serve the backend's admin console at /_gm behind HTTP Basic-Auth for a local non-caddy run; in the deployed contour the front caddy owns /_gm (see ../deploy). See ../docs/ARCHITECTURE.md §2, §3, §10, §12, §13.

Package layout

cmd/gateway/            # main: config -> backend client -> session cache ->
                        #   push hub -> Connect h2c server (+ admin) -> serve
proto/edge/v1/          # Connect envelope contract (committed generated Go)
internal/config/        # GATEWAY_* env config
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
internal/session/       # in-memory session cache (LRU/TTL, backend fallback)
internal/ratelimit/     # token-bucket limiter (golang.org/x/time/rate) + the rejection tracker + the temporary IP banlist
internal/connector/     # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
internal/push/          # live-event fan-out hub (per-user client streams)
internal/transcode/     # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/    # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
internal/admin/         # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
internal/webui/         # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ + /vk/

The FlatBuffers payloads and the backend push proto are the shared wire contracts in ../pkg.

Transport contract

A single Gateway Connect service: Execute(message_type, payload, request_id) for unary operations and Subscribe for the live stream. The payload bytes are FlatBuffers tables (scrabble/pkg/fbs); the gateway transcodes them to and from the backend's JSON. The session token rides in Authorization: Bearer; auth.* operations are unauthenticated and return the minted token. A unary domain outcome rides back in ExecuteResponse.result_code (HTTP 200); only edge failures become Connect error codes.

auth.telegram validates the Mini App initData by calling the Telegram validator (GATEWAY_VALIDATOR_ADDR), which holds the bot token (HMAC); out-of-app push for recipients with no live in-app stream goes to the remote bot over the reverse mTLS bot-link (GATEWAY_BOTLINK_ADDR, fire-and-forget), and the backend admin broadcasts arrive on the gateway's plaintext relay (GATEWAY_BOTLINK_RELAY_ADDR) which forwards them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). When GATEWAY_VALIDATOR_ADDR is unset Telegram auth is disabled; when GATEWAY_BOTLINK_ADDR is unset the bot channel (out-of-app push + admin relay) is disabled.

auth.vk validates a VK Mini App launch in-process (internal/vkauth): it verifies the signed vk_* launch parameters against the VK app's protected key (GATEWAY_VK_APP_SECRET) — HMAC-SHA256 over the sorted params, base64url — a pure offline check with no side-service or VK API round-trip, then forwards the trusted vk_user_id (and the client-read display name, which VK omits from the signed params) to the backend. When GATEWAY_VK_APP_SECRET is unset VK auth is disabled (auth.vk is unregistered).

link.vk.confirm/merge link a VK identity from a browser (not a Mini App) via VK ID web login (internal/vkid): the SPA runs the VK ID raw OAuth 2.1 flow (PKCE, no @vkid/sdk) and the gateway completes the confidential code exchange at id.vk.com under a SEPARATE VK "Web" app's protected key — the only op here that calls VK (the Mini App path above is offline). All three GATEWAY_VK_ID_* unset leaves the ops unregistered.

The message-type catalog: auth.telegram, auth.vk, auth.guest, auth.email.request, auth.email.login, profile.get, game.submit_play, game.state, lobby.enqueue, lobby.poll, chat.post, chat.read and the play-loop ops; live events your_turn, opponent_moved, chat_message, nudge, match_found (the game events — and game_over/notify — carry the state delta the client applies without a game.state refetch). The social/account/history ops — friends.* (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem), blocks.*, invitation.* (list/create/accept/decline/cancel), profile.update, stats.get, game.gcg, and the notify live event — go through the identical transcode pattern (transcode_social.go). Account linking & merge — link.email.request/confirm/merge, link.telegram.confirm/merge and link.vk.confirm/merge (transcode_link.go); the telegram ops validate the Login Widget payload via the validator (ValidateLoginWidget), the vk ops complete the VK ID web code exchange via internal/vkid, and both forward the trusted external_id. These superseded the former email.bind.* ops, which were removed.

Configuration

Variable Default Notes
GATEWAY_HTTP_ADDR :8081 public Connect/h2c listener (also serves the admin console at /_gm)
GATEWAY_LOG_LEVEL info zap level
GATEWAY_BACKEND_HTTP_URL http://localhost:8080 backend REST base URL
GATEWAY_BACKEND_GRPC_ADDR localhost:9090 backend push gRPC address
GATEWAY_BACKEND_TIMEOUT 5s per backend REST call
GATEWAY_ADMIN_USER / GATEWAY_ADMIN_PASSWORD unset enable + guard the admin console at /_gm
GATEWAY_VALIDATOR_ADDR unset Telegram validator gRPC address (enables initData / Login Widget validation)
GATEWAY_VK_APP_SECRET unset VK app protected key; enables in-process VK Mini App launch-signature verification (auth.vk)
GATEWAY_VK_ID_APP_ID / GATEWAY_VK_ID_CLIENT_SECRET / GATEWAY_VK_ID_REDIRECT_URL unset VK ID "Web" app credentials for VK web-login linking (link.vk.*): the server-side confidential OAuth 2.1 code exchange. A separate VK app from GATEWAY_VK_APP_SECRET; all three required to enable the ops
GATEWAY_BOTLINK_ADDR unset reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay)
GATEWAY_BOTLINK_RELAY_ADDR unset plaintext internal listener serving the backend admin SendToUser/SendToGameChannel relay
GATEWAY_BOTLINK_TLS_CERT / _KEY / _CA unset gateway server cert, its key, and the CA that signs accepted bot client certs (required when GATEWAY_BOTLINK_ADDR is set)
GATEWAY_BOTLINK_SEND_TIMEOUT 5s admin relay wait for the bot ack before reporting not-delivered
GATEWAY_SESSION_TTL 10m cached session lifetime
GATEWAY_SESSION_CACHE_MAX 50000 cached session cap
GATEWAY_PUSH_HEARTBEAT_INTERVAL 10s live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout)
GATEWAY_MAX_BODY_BYTES 1048576 caps one request body and one Connect message read; an oversized Execute is refused with resource_exhausted
GATEWAY_ABUSE_BAN_ENABLED false enable the temporary IP ban (prod-only — keys by real client IP, off in the shared-NAT test contour)
GATEWAY_ABUSE_BAN_THRESHOLD 100 rate-limiter rejections within the window that ban an IP
GATEWAY_ABUSE_BAN_WINDOW 2m rolling window the rejection strikes accumulate over
GATEWAY_ABUSE_BAN_DURATION 15m length of a rejection-earned ban (tripwire 1h, honeytoken 24h are fixed)
GATEWAY_HONEYTOKEN unset planted bearer value; presenting it bans the caller and raises an alarm
GATEWAY_SERVICE_NAME scrabble-gateway OpenTelemetry service.name
GATEWAY_OTEL_TRACES_EXPORTER none none, stdout or otlp (gRPC; endpoint from OTEL_EXPORTER_OTLP_*)
GATEWAY_OTEL_METRICS_EXPORTER none none, stdout or otlp

Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated 300/min·user (burst 80, sized for multi-device play), admin 60/min·IP (burst 20, guarding the /_gm mount ahead of its Basic-Auth), email-code 5/10 min·IP (burst 4, covering request + a mistyped code + the correct one; defence-in-depth over the backend's per-code 5-attempt/15-min cap).

Every rejection increments gateway_rate_limited_total{class} (user/public/email/admin) and logs one Debug line; a reporter drains the per-key rejection tracker every 30 s, emits a Warn summary per throttled key and posts the report to the backend (/api/v1/internal/ratelimit/report), feeding the admin console's throttled view and the high-rate auto-flag.

Temporary IP ban (prod-only, GATEWAY_ABUSE_BAN_ENABLED): a fail2ban-style block keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed classes), a honeypot decoy-path hit (the contour caddy tags decoy paths with X-Scrabble-Honeypot), and a honeytoken (GATEWAY_HONEYTOKEN). A banned IP is refused with 429 by the abuseGuard edge middleware before any work — covering the Connect edge, the live stream and the static SPA/landing. Each ban increments gateway_abuse_banned_total{reason} (rejections/tripwire/honeytoken). The ban is in-memory (resets on restart); it is off by default because it keys by the real client IP, which the shared-NAT test contour does not expose (detection still logs there, only the ban action is gated). The gateway syncs its active set to the backend every 30 s (/api/v1/internal/bans/sync) for the console's Active IP bans panel and applies the operator unbans the response returns.

Run

GATEWAY_BACKEND_HTTP_URL=http://localhost:8080 \
GATEWAY_BACKEND_GRPC_ADDR=localhost:9090 \
go run ./gateway/cmd/gateway   # Connect edge on :8081

Generated code

The Connect envelope Go is committed under proto/edge/v1. Regenerate after editing the .proto (dev-time, like backend/cmd/jetgen):

make -C gateway tools   # go install protoc-gen-go + protoc-gen-connect-go
make -C gateway gen     # buf generate (local plugins)

The FlatBuffers payloads are generated in ../pkg (make -C pkg fbs).

Tests

go test -count=1 ./gateway/...

All gateway tests are hermetic: no real network, a fake backend (httptest) and credential fixtures. There is no integration (Docker) suite — the gateway holds no database.