Files
scrabble-game/gateway/internal/connectsrv/server.go
T
Ilia Denisov 2d1fadb50c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
feat(offline): implicit net-state model, two-tier version gate, unified lobby
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
2026-07-13 02:50:41 +02:00

969 lines
38 KiB
Go

// Package connectsrv implements the public Connect edge service over h2c. Execute
// rate-limits, authenticates (resolving the Authorization bearer token to a user
// id for non-auth operations), and dispatches to the transcode registry; the
// domain outcome is carried back in the ExecuteResponse result_code. Subscribe
// bridges the gateway push hub to a client server-stream with a keep-alive
// heartbeat.
package connectsrv
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"connectrpc.com/connect"
"go.opentelemetry.io/otel/metric"
"go.uber.org/zap"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/clientver"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
"scrabble/gateway/internal/vkpay"
"scrabble/gateway/internal/webui"
edgev1 "scrabble/gateway/proto/edge/v1"
"scrabble/gateway/proto/edge/v1/edgev1connect"
)
// heartbeatKind is the live-stream keep-alive event kind.
const heartbeatKind = "heartbeat"
// honeypotHeader marks a request the edge proxy routed from a honeypot decoy path;
// any request carrying it is treated as a scanner hit. The proxy strips any
// client-supplied value before setting its own, and a spoofed value only bans the
// spoofer, so trusting it is safe.
const honeypotHeader = "X-Scrabble-Honeypot"
// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
// the edge can turn away a build too old to speak the current wire contract, before it decodes
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
// "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
// on a served Execute response when the client is at or above the hard minimum but below the
// recommended version, it nudges an update without failing the call. Headers are the
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
// contract (docs/ARCHITECTURE.md §2).
const (
clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required"
updateRecommendedHeader = "X-Update-Recommended"
)
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
// class field of the periodic rejection report.
const (
classUser = "user"
classPublic = "public"
classEmail = "email"
classAdmin = "admin"
)
// Explicit h2c server sizing, made explicit rather than relying on the
// implicit defaults.
const (
// h2cMaxConcurrentStreams bounds the open streams per client connection — the
// x/net default made explicit. A real client holds one Subscribe stream plus a
// few unary calls; only a synthetic load multiplexing many players over one
// transport approaches it.
h2cMaxConcurrentStreams = 250
// h2cIdleTimeout closes a connection with no open streams. A live Subscribe
// stream keeps its connection active, so long-lived clients are unaffected;
// only abandoned connections are reaped.
h2cIdleTimeout = 3 * time.Minute
)
// Server implements edgev1connect.GatewayHandler.
type Server struct {
registry *transcode.Registry
sessions *session.Cache
backend *backendclient.Client
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
blocklist *ratelimit.Blocklist
honeytoken string
vkAppSecret string
hub *push.Hub
heartbeat time.Duration
log *zap.Logger
adminProxy http.Handler
metrics *serverMetrics
maxBodyBytes int
// minClient is the lowest client version served; gateOn is false when the gate is dormant
// (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
minClient clientver.Version
gateOn bool
// recClient is the version below which a served client is nudged to update (the soft tier);
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
// unparseable). It is independent of the hard gate.
recClient clientver.Version
recOn bool
publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy
adminPolicy ratelimit.Policy
}
// Deps carries the Server's dependencies. A nil Limiter, nil Tracker, zero
// RateLimit and non-positive MaxBodyBytes each select a safe default.
type Deps struct {
Registry *transcode.Registry
Sessions *session.Cache
// Backend is the REST client backing the session-gated /dict blob route; a nil
// value disables that route (it 404s).
Backend *backendclient.Client
Limiter *ratelimit.Limiter
// Tracker accumulates limiter rejections for the periodic report; nil
// selects a private tracker (rejections are then only counted, never
// reported).
Tracker *ratelimit.Tracker
// Banlist enforces temporary IP bans on the hot path; nil selects a disabled
// (inert) banlist.
Banlist *ratelimit.Banlist
// Blocklist enforces the community IP blocklist on the hot path; nil selects a
// disabled (inert) blocklist.
Blocklist *ratelimit.Blocklist
// Honeytoken, when non-empty, is the planted bearer value whose presentation
// bans the caller and raises a high-severity alarm.
Honeytoken string
// VKAppSecret is the VK Mini App protected key; it verifies the VK payment callback signature
// (the same key the launch-signature auth uses). Empty leaves the VK callback rejecting.
VKAppSecret string
Hub *push.Hub
RateLimit config.RateLimitConfig
Heartbeat time.Duration
Logger *zap.Logger
AdminProxy http.Handler
Meter metric.Meter
// MaxBodyBytes caps one inbound request body and one Connect message read;
// zero or negative selects config.DefaultMaxBodyBytes.
MaxBodyBytes int
// MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
// the gate dormant.
MinClientVersion string
// RecommendedClientVersion is the version below which a served client is nudged to update (the
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
RecommendedClientVersion string
}
// NewServer constructs the edge service.
func NewServer(d Deps) *Server {
log := d.Logger
if log == nil {
log = zap.NewNop()
}
maxBody := d.MaxBodyBytes
if maxBody <= 0 {
maxBody = config.DefaultMaxBodyBytes
}
tracker := d.Tracker
if tracker == nil {
tracker = ratelimit.NewTracker()
}
limiter := d.Limiter
if limiter == nil {
limiter = ratelimit.New()
}
banlist := d.Banlist
if banlist == nil {
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
}
blocklist := d.Blocklist
if blocklist == nil {
blocklist = ratelimit.NewBlocklist(false, nil)
}
rl := d.RateLimit
if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit()
}
// Parse the minimum client version once. Config.validate already rejects an unparseable
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
// the gate dormant.
var minClient clientver.Version
gateOn := false
if d.MinClientVersion != "" {
if v, ok := clientver.Parse(d.MinClientVersion); ok {
minClient, gateOn = v, true
} else {
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
}
}
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
// construction; an empty value leaves the soft tier dormant.
var recClient clientver.Version
recOn := false
if d.RecommendedClientVersion != "" {
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
recClient, recOn = v, true
} else {
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
}
}
return &Server{
registry: d.Registry,
sessions: d.Sessions,
backend: d.Backend,
vkAppSecret: d.VKAppSecret,
limiter: limiter,
tracker: tracker,
banlist: banlist,
blocklist: blocklist,
honeytoken: d.Honeytoken,
hub: d.Hub,
heartbeat: d.Heartbeat,
log: log,
adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter, blocklist),
maxBodyBytes: maxBody,
minClient: minClient,
gateOn: gateOn,
recClient: recClient,
recOn: recOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
adminPolicy: ratelimit.PerMinute(rl.AdminPerMinute, rl.AdminBurst),
}
}
// HTTPHandler returns the h2c-wrapped Connect handler ready to serve.
func (s *Server) HTTPHandler() http.Handler {
mux := http.NewServeMux()
// The Connect read cap mirrors the HTTP-level body cap below; an oversized
// Execute message is refused (resource_exhausted) instead of buffered.
path, h := edgev1connect.NewGatewayHandler(s, connect.WithReadMaxBytes(s.maxBodyBytes))
mux.Handle(path, h)
if s.adminProxy != nil {
// The admin console (backend /_gm) is served on the public listener behind
// the proxy's Basic-Auth, mounted below the h2c wrap so the Connect edge keeps
// working over h2c (docs/ARCHITECTURE.md §12). In the deployed contour the
// front caddy owns the /_gm Basic-Auth and Grafana routing and proxies /_gm to
// the backend directly (bypassing this mount); this mount serves a non-caddy
// (local) setup. The per-IP admin limiter class guards it — notably a Basic-Auth
// brute force. Note: the shared maxBodyHandler cap (1 MiB by default) also covers
// this mount, so a dictionary-archive upload through the gateway-fronted console
// needs a larger GATEWAY_MAX_BODY_BYTES; the contour path (caddy → backend) is not
// affected and the backend self-caps the upload.
mux.Handle("/_gm/", s.limitAdmin(s.adminProxy))
} else {
// With the console disabled here, keep /_gm a 404 so the SPA catch-all below
// does not serve the app shell at the operator path.
mux.Handle("/_gm/", http.NotFoundHandler())
}
// The client-side local move preview pulls each game's pinned dictionary blob
// through this session-gated route (not public); see dictBytesHandler.
mux.Handle("/dict/", s.dictBytesHandler())
mux.Handle("/dl/", s.exportDownloadHandler())
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects.
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
// The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
// screen (the app cannot run). Unauthenticated — the client never booted — but rate-limited.
mux.Handle("/telemetry/unsupported", s.unsupportedEngineHandler())
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
// priority, and each mount falls back to the app shell (index.html) for the hash
// router. The public landing lives in its own static container behind the contour
// caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a
// local no-caddy run usable.
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
mux.Handle("/vk/", webui.Handler("/vk/", "index.html"))
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
// honeypot hit is turned away before the body cap and the mux. Every request
// body on the public listener is then capped (the admin proxy POSTs included);
// the h2c server carries explicit stream/idle sizing.
return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{
MaxConcurrentStreams: h2cMaxConcurrentStreams,
IdleTimeout: h2cIdleTimeout,
})
}
// maxBodyHandler caps every inbound request body at limit bytes: a read past the
// cap fails with *http.MaxBytesError and the connection is marked to close.
func maxBodyHandler(limit int, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, int64(limit))
next.ServeHTTP(w, r)
})
}
// abuseGuard refuses a banned client IP with 429 before any work, and turns a
// honeypot decoy hit (the proxy-set honeypotHeader) into an instant ban plus a
// bland 404 that is indistinguishable from an ordinary miss. The ban check and the
// tripwire ban are inert on a disabled banlist (the prod-only gate); the tripwire
// hit is logged either way as scanner telemetry.
func (s *Server) abuseGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := peerIP(r.RemoteAddr, r.Header)
if s.banlist.Banned(ip) {
http.Error(w, "banned", http.StatusTooManyRequests)
return
}
// The community IP blocklist (Spamhaus DROP): a known-bad source is refused before any work.
// Counted, not logged per request (a blocked scanner hammers). Inert on a disabled blocklist.
if s.blocklist.Blocked(ip) {
s.metrics.recordBlocklistBlock(r.Context())
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Header.Get(honeypotHeader) != "" {
s.log.Warn("honeypot tripwire",
zap.String("path", r.URL.Path),
zap.String("client_ip", ip))
if s.banlist.BanNow(ip, ratelimit.ReasonTripwire) {
s.metrics.recordBan(r.Context(), string(ratelimit.ReasonTripwire))
}
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// clientTooOld reports whether the X-Client-Version header names a version below the configured
// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
// false. The header is a client-controlled compatibility signal, not an access control (it is
// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
// non-browser caller — must never be blocked spuriously.
func (s *Server) clientTooOld(header string) bool {
if !s.gateOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return clientver.Less(v, s.minClient)
}
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
// band — at or above the hard minimum but below the recommended version — so a served response can
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
// leaves minClient at the zero version, which no real version is below, so the band is simply
// "below recommended").
func (s *Server) clientUpdateRecommended(header string) bool {
if !s.recOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
}
// Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors.
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
start := time.Now()
msgType := req.Msg.GetMessageType()
result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
// client at or above the hard minimum but below the recommended version is told an update is
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
// version yields no nudge.
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
defer func() {
if recommend && resp != nil {
resp.Header().Set(updateRecommendedHeader, "1")
}
}()
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
// the payload is decoded, so a too-old client makes zero successful calls but sees the
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
result = resultUpdateRequired
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: resultUpdateRequired,
}), nil
}
op, ok := s.registry.Lookup(msgType)
if !ok {
result = "unknown_type"
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
}
clientIP := peerIP(req.Peer().Addr, req.Header())
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
if op.Auth {
uid, isGuest, platform, err := s.resolve(ctx, req.Header(), clientIP)
if err != nil {
result = "unauthenticated"
return nil, err
}
// A guest may not perform a non-guest operation: reject it here as a domain
// outcome, before any backend call.
if op.NonGuest && isGuest {
result = "domain"
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: "guest_forbidden",
}), nil
}
// A valid session proving an authenticated request is an "action" for the
// active_users gauge, counted before the rate-limit/domain outcome.
s.metrics.recordActive(uid)
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
result = "rate_limited"
return nil, s.rejectRateLimited(ctx, classUser, uid, msgType)
}
tr.UserID = uid
// Carry the resolved trusted platform on the context so the backend client injects
// X-Platform on every downstream REST call for this request. Empty for an untrusted
// session ⇒ no header ⇒ the backend treats the request as untrusted (view-only).
ctx = backendclient.WithPlatform(ctx, platform)
} else {
if !s.limiter.Allow("ip:"+clientIP, s.publicPolicy) {
result = "rate_limited"
return nil, s.rejectRateLimited(ctx, classPublic, clientIP, msgType)
}
if op.Email && !s.limiter.Allow("email:"+clientIP, s.emailPolicy) {
result = "rate_limited"
return nil, s.rejectRateLimited(ctx, classEmail, clientIP, msgType)
}
}
payload, err := op.Handler(ctx, tr)
if err != nil {
if code, domain := transcode.DomainCode(err); domain {
result = "domain"
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: code,
}), nil
}
s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err))
return nil, connect.NewError(connect.CodeInternal, errInternal)
}
result = "ok"
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: "ok",
Payload: payload,
}), nil
}
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
// A stream carries no result_code, so a too-old client is refused with the Connect
// FailedPrecondition counterpart of the update_required sentinel.
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired)
}
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
if err != nil {
return err
}
if !s.limiter.Allow("user:"+uid, s.userPolicy) {
return s.rejectRateLimited(ctx, classUser, uid, "subscribe")
}
events, cancel := s.hub.Subscribe(uid)
defer cancel()
// Send an immediate heartbeat so the stream's first byte flushes through the proxy chain
// right away and resets edge/client idle timers, instead of the connection sitting silent
// until the first tick — which otherwise raced a ~15 s idle timeout and forced a reconnect
// every interval.
if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
return err
}
ticker := time.NewTicker(s.heartbeat)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := stream.Send(&edgev1.Event{Kind: heartbeatKind}); err != nil {
return err
}
case e, ok := <-events:
if !ok {
return nil
}
if err := stream.Send(&edgev1.Event{Kind: e.Kind, Payload: e.Payload, EventId: e.EventID}); err != nil {
return err
}
}
}
}
// noteRateLimited accounts one limiter rejection: the aggregate counter, the
// per-rejection Debug line and the periodic-report tracker. The operational
// signal is the reporter's Warn summary; per-rejection logging stays at Debug so
// a rejection flood cannot flood the log.
func (s *Server) noteRateLimited(ctx context.Context, class, key, msgType string) {
s.metrics.recordRateLimited(ctx, class)
s.tracker.Add(class, key)
// IP-keyed rejections (public, email, admin — the key is the client IP) feed
// the ban; the user class is keyed by account id and is the backend soft-flag's
// concern, not the IP ban's.
if class != classUser && s.banlist.Strike(key) {
s.metrics.recordBan(ctx, string(ratelimit.ReasonRejections))
}
s.log.Debug("rate limited",
zap.String("class", class),
zap.String("key", key),
zap.String("message_type", msgType))
}
// rejectRateLimited accounts one limiter rejection and returns the Connect error
// for the caller.
func (s *Server) rejectRateLimited(ctx context.Context, class, key, msgType string) error {
s.noteRateLimited(ctx, class, key, msgType)
return connect.NewError(connect.CodeResourceExhausted, errRateLimited)
}
// limitAdmin guards the admin proxy with the per-IP admin limiter class, ahead
// of its Basic-Auth check (a credential brute force is exactly what it bounds).
// It covers the gateway-fronted /_gm mount; in the deployed contour /_gm reaches
// the backend through caddy, whose Basic-Auth has no limiter (stock caddy) — see
// docs/ARCHITECTURE.md §12.
func (s *Server) limitAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("admin:"+ip, s.adminPolicy) {
s.noteRateLimited(r.Context(), classAdmin, ip, "admin")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// dictBytesHandler serves the raw dictionary blob for a (variant, version) pair to
// a signed-in client (the local move preview), proxying the backend's authed
// endpoint. It is session-gated — not public — bounds the download with the
// user-class limiter, and forwards the backend's immutable Cache-Control so the
// browser caches the blob hard. Only GET is allowed; the path is
// /dict/{variant}/{version}.
// exportDownloadHandler serves the signed finished-game export downloads (/dl/*).
// It is the gateway's only unauthenticated data route: the platforms' native
// download calls (Telegram downloadFile, VKWebAppDownloadFile, a plain anchor)
// carry no session, so the URL's HMAC — verified by the backend — is the whole
// grant. The gateway only rate-limits by IP and forwards, passing the public Host
// along for the image footer.
func (s *Server) exportDownloadHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "export-dl")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
rest := strings.TrimPrefix(r.URL.Path, "/dl")
if rest == "" || rest == "/" {
http.NotFound(w, r)
return
}
if r.URL.RawQuery != "" {
rest += "?" + r.URL.RawQuery
}
data, contentType, disposition, err := s.backend.ExportDownload(r.Context(), rest, r.Host)
if err != nil {
var apiErr *backendclient.APIError
if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError {
http.NotFound(w, r) // invalid, expired or unknown link
return
}
s.log.Warn("export download failed", zap.Error(err))
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
if disposition != "" {
w.Header().Set("Content-Disposition", disposition)
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, max-age=0")
// ServeContent (not a bare Write): the platforms' native downloaders are picky —
// Android's system DownloadManager (the VKWebAppDownloadFile executor) hangs on a
// chunked body of unknown length and may probe with Range. ServeContent emits
// Content-Length, honours Range/If-* and answers 206s from the buffered artifact.
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data))
})
}
// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
// so it retries. The gateway does not verify the signature — the backend holds the secret.
func (s *Server) robokassaResultHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "robokassa-result")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
params := make(map[string]string, len(r.Form))
for k := range r.Form {
params[k] = r.Form.Get(k)
}
resp, err := s.backend.RobokassaResult(r.Context(), params)
if err != nil {
s.log.Warn("robokassa result proxy failed", zap.Error(err))
http.Error(w, "not accepted", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(resp))
})
}
// vkCallbackHandler proxies a VK Mini Apps payment callback to the backend intake. It rate-limits
// per IP, verifies the VK signature with the app's protected key (the backend holds no VK secret),
// forwards the provider's parameters, and relays the backend's VK response envelope. A missing or
// bad signature is rejected before reaching the backend.
func (s *Server) vkCallbackHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "vk-callback")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
params := make(map[string]string, len(r.Form))
for k := range r.Form {
params[k] = r.Form.Get(k)
}
if !vkpay.Verify(params, s.vkAppSecret) {
s.log.Warn("vk callback: bad signature")
http.Error(w, "bad signature", http.StatusForbidden)
return
}
resp, err := s.backend.VKCallback(r.Context(), params)
if err != nil {
s.log.Warn("vk callback proxy failed", zap.Error(err))
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(resp)
})
}
// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser
// return. The payment opens in a separate window, so on return this closes that window and drops the
// customer back into the live app (never a cold app start); a link is the fallback if the window
// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet
// updates in place from the payment push, with a return-focus refetch as the fallback.
func (s *Server) robokassaReturnHandler(message string) http.Handler {
page := []byte(`<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Оплата</title></head>
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 20px; color: #1a1c20">
<p style="font-size: 1.1rem">` + message + `</p>
<p style="color: #5b6472">Можно закрыть это окно и вернуться в приложение.</p>
<p><a href="/app/">Открыть приложение</a></p>
<script>setTimeout(function () { try { window.close(); } catch (e) {} }, 1200);</script>
</body></html>`)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(page)
})
}
func (s *Server) dictBytesHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("user:"+ip, s.userPolicy) {
s.noteRateLimited(r.Context(), classUser, ip, "dict")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
uid, _, _, err := s.resolve(r.Context(), r.Header, ip)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
variant, version, ok := parseDictPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
data, cacheControl, err := s.backend.DictBytes(r.Context(), uid, variant, version)
if err != nil {
var apiErr *backendclient.APIError
if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError {
http.NotFound(w, r) // unknown variant or version
return
}
s.log.Warn("dict fetch failed", zap.Error(err))
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
if cacheControl != "" {
w.Header().Set("Cache-Control", cacheControl)
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(data)
})
}
// parseDictPath splits /dict/{variant}/{version}; both segments must be present
// and non-empty, and the version must not contain a further slash.
func parseDictPath(p string) (variant, version string, ok bool) {
rest, found := strings.CutPrefix(p, "/dict/")
if !found {
return "", "", false
}
i := strings.IndexByte(rest, '/')
if i <= 0 || i == len(rest)-1 {
return "", "", false
}
variant, version = rest[:i], rest[i+1:]
if strings.Contains(version, "/") {
return "", "", false
}
return variant, version, true
}
// localEvalMetricsHandler ingests a client's local move-preview telemetry batch into the
// edge's adoption counters (docs/ARCHITECTURE.md §11). Session-gated so only real clients
// report; the values are aggregate (no per-user attributes) and clamped against a spoofed
// inflation. Only POST; the body is a small JSON of per-metric deltas.
func (s *Server) localEvalMetricsHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if _, _, _, err := s.resolve(r.Context(), r.Header, ip); err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var rep localEvalReport
if err := json.NewDecoder(io.LimitReader(r.Body, 512)).Decode(&rep); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
clampReport(&rep)
s.metrics.recordLocalEval(r.Context(), rep)
w.WriteHeader(http.StatusNoContent)
})
}
// clampReport bounds each counter of a client report against a spoofed inflation — one batch
// reflects at most a few minutes of a single client's activity.
func clampReport(r *localEvalReport) {
const maxPerField = 1000
clamp := func(n *int) {
if *n < 0 {
*n = 0
} else if *n > maxPerField {
*n = maxPerField
}
}
clamp(&r.ColdStart)
clamp(&r.DictFetched)
clamp(&r.DictCacheHit)
clamp(&r.DictMiss)
clamp(&r.PreviewLocal)
clamp(&r.PreviewNetwork)
}
// unsupportedEngineBeacon is the small fire-and-forget report the index.html boot guard sends when
// it turns a client away on the unsupported-engine screen (no BigInt/Proxy, or an uncaught boot
// error). It is deduped client-side (one per device / app version / reason).
type unsupportedEngineBeacon struct {
Reason string `json:"reason"`
Chromium string `json:"chromium"`
Version string `json:"version"`
UA string `json:"ua"`
}
// unsupportedEngineHandler folds one unsupported-engine beacon into the edge counter. It is
// unauthenticated (the client never booted, so it carries no session) but per-IP rate-limited with
// the public limiter and body-capped. reason and the Chromium major are reduced to bounded label
// sets (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full
// user agent is logged, not labelled. Only POST; the reply is always 204.
func (s *Server) unsupportedEngineHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "unsupported")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
var b unsupportedEngineBeacon
if err := json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&b); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
reason, chromium := normalizeUnsupported(b.Reason, b.Chromium)
s.metrics.recordUnsupportedEngine(r.Context(), reason, chromium)
s.log.Info("unsupported engine",
zap.String("reason", reason),
zap.String("chromium", chromium),
zap.String("app_version", truncate(b.Version, 40)),
zap.String("user_agent", truncate(b.UA, 400)),
)
w.WriteHeader(http.StatusNoContent)
})
}
// normalizeUnsupported reduces a beacon's reason and Chromium fields to bounded label values, so a
// spoofed beacon cannot explode the metric cardinality: reason is allow-listed, and Chromium is
// parsed as a major version kept only within a plausible range, otherwise "other".
func normalizeUnsupported(reason, chromium string) (string, string) {
switch reason {
case "no_bigint", "no_proxy", "boot_error":
// a recognised reason — keep as-is
default:
reason = "other"
}
major := "other"
if n, err := strconv.Atoi(strings.TrimSpace(chromium)); err == nil && n >= 1 && n <= 199 {
major = strconv.Itoa(n)
}
return reason, major
}
// truncate bounds a logged, client-supplied string to n bytes (a spoofed beacon field is not
// trusted to be small).
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.
func (s *Server) resolve(ctx context.Context, h http.Header, clientIP string) (string, bool, string, error) {
token := bearerToken(h.Get("Authorization"))
if token == "" {
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errMissingToken)
}
// The honeytoken is a planted value no real client holds: presenting it is a
// high-confidence intrusion signal, so ban the caller and raise the alarm, then
// return the ordinary invalid-session error so the trap stays indistinguishable.
if s.honeytoken != "" && subtle.ConstantTimeCompare([]byte(token), []byte(s.honeytoken)) == 1 {
s.log.Warn("honeytoken presented", zap.String("client_ip", clientIP))
if s.banlist.BanNow(clientIP, ratelimit.ReasonHoneytoken) {
s.metrics.recordBan(ctx, string(ratelimit.ReasonHoneytoken))
}
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
uid, isGuest, platform, err := s.sessions.Resolve(ctx, token)
if err != nil {
// An unknown or expired token (a backend 4xx) is the client's problem and
// stays silent; anything else — a resolve timeout, a refused connection, a
// backend 5xx — is an infra failure misread as "unauthenticated" by the
// client, so surface the cause (the transient resolves seen under load).
// The token itself is never logged.
var apiErr *backendclient.APIError
if !errors.As(err, &apiErr) || apiErr.Status >= http.StatusInternalServerError {
s.log.Warn("session resolve failed", zap.Error(err))
}
return "", false, "", connect.NewError(connect.CodeUnauthenticated, errInvalidSession)
}
return uid, isGuest, platform, nil
}
// bearerToken extracts the token from an "Authorization: Bearer <token>" header,
// tolerating a bare token for convenience.
func bearerToken(header string) string {
header = strings.TrimSpace(header)
if header == "" {
return ""
}
if rest, ok := strings.CutPrefix(header, "Bearer "); ok {
return strings.TrimSpace(rest)
}
return header
}
// peerIP prefers the X-Forwarded-For client hop, falling back to the connection
// peer address (host part).
func peerIP(peerAddr string, h http.Header) string {
if xff := h.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
return strings.TrimSpace(first)
}
if host, _, err := net.SplitHostPort(peerAddr); err == nil {
return host
}
return peerAddr
}