Add a prod-only, in-memory IP ban enforced at the edge, fed by three signals:
sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the
user class stays the backend soft-flag's concern), a honeypot decoy-path hit (the
contour caddy tags decoys with X-Scrabble-Honeypot and routes them to the gateway),
and a honeytoken (a planted bearer, GATEWAY_HONEYTOKEN). A banned IP is refused with
429 by the abuseGuard middleware before any work — covering the Connect edge, the
live stream and the static SPA/landing the per-op limiter never gated.
The ban is off by default: it keys by the real client IP the shared-NAT test contour
does not expose, so a ban there would be self-inflicted; detection still logs in the
contour, only the ban action is gated (GATEWAY_ABUSE_BAN_ENABLED). Rejection bans last
GATEWAY_ABUSE_BAN_DURATION; tripwire/honeytoken hits are near-zero-false-positive and
earn longer fixed bans. Each ban increments gateway_abuse_banned_total{reason}.
Operators see and lift active bans on the admin console's Throttled page; the gateway
syncs its active set to the backend every 30s (POST /api/v1/internal/bans/sync,
backend/internal/banview) and applies the operator unbans the response returns.
PRERELEASE phase AG. Docs baked into ARCHITECTURE / FUNCTIONAL (+ru) / both READMEs.
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) and /telegram/ (the Mini App) — the single-origin model.
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/
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.
The message-type catalog: auth.telegram, 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 and link.telegram.confirm/merge
(transcode_link.go); the telegram ops validate the Login Widget payload via the
validator (ValidateLoginWidget) and 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_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 2).
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.