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).
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.