// 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. The // per-shop callback rides a channel suffix (/pay/robokassa/result/); the bare path is // the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change. mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) 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(withNativeCORS(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()) // Carry the client IP on the context so the backend client injects it as X-Forwarded-For on every // downstream REST call for this request — the account's last-login IP, chat/feedback moderation and // the backend access log, not only the chat/feedback calls that pass it explicitly. ctx = backendclient.WithClientIP(ctx, clientIP) 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, Message: transcode.DomainMessage(err), }), 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)) }) } // robokassaResultPrefix is the channel-suffixed Result path; the segment after it names the // per-shop channel (matches the direct-rail X-Platform subtype: "web", "android"). const robokassaResultPrefix = "/pay/robokassa/result/" // robokassaChannel extracts the shop channel from a Result callback path: the segment after // robokassaResultPrefix, or "" for the legacy bare /pay/robokassa/result (the backend then defaults // to the web shop). The backend selects the per-shop signature verifier from it. func robokassaChannel(path string) string { rest := strings.TrimPrefix(path, robokassaResultPrefix) if rest == path { return "" } return rest } // 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" 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(), robokassaChannel(r.URL.Path), 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(` Оплата

` + message + `

Можно закрыть это окно и вернуться в приложение.

Открыть приложение

`) 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 " 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 }