2c465c01d2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.
- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
transcode link.vk.confirm/merge (registered only when configured) + config
GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
boot callback handling; a merge re-authorizes for a fresh code (VK codes are
single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
170 lines
11 KiB
Markdown
170 lines
11 KiB
Markdown
# 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.
|
|
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`](../deploy)). See
|
|
[`../docs/ARCHITECTURE.md`](../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`](../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 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
|
|
|
|
```sh
|
|
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`):
|
|
|
|
```sh
|
|
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`](../pkg) (`make -C pkg fbs`).
|
|
|
|
## Tests
|
|
|
|
```sh
|
|
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.
|