Files
scrabble-game/gateway/internal/config/config.go
T
Ilia Denisov fe5a3d6d3b
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
fix(login): stop a rate-limit from latching a phantom offline on the login screen
A fresh email login that hit the per-IP email rate limit stranded the user in
an unrecoverable "offline" state that survived a full PWA restart, even though
the network was fine.

Root cause: the gateway email class (auth.email.request + auth.email.login,
keyed per IP, 5/10min burst 2) trips on the third event, which in the natural
"request code -> wrong code -> correct code" sequence is the correct-code login.
The gateway returns ResourceExhausted; the client mapped it to 'rate_limited',
which retry.ts classified as retryable + a connection code, so exec() called
reportOffline(). That pushes the net-state machine into connecting, whose
recovery probe is an authenticated profile.get -- but the login screen has no
session, so the probe can never succeed and the machine latches offline. The
transport kill switch (assertOnline) then refuses the very login that would fix
it, and because the IP's email bucket refills only 1 token / 120s, each fresh
attempt after a restart is rate-limited again -> offline again.

Fix (client, narrow -- the trigger):
- A rate-limit is no longer treated as connectivity. retry.ts no longer marks
  'rate_limited' retryable or a connection code, so exec() never reports it
  offline; it surfaces as the existing error.rate_limited "slow down" message.
  Not auto-retrying it also stops the ~20s button freeze and avoids feeding the
  gateway's ban tripwire with 6 extra rejections per attempt.

Fix (server):
- Raise the email-code burst 2 -> 4 so the honest request + a mistyped code +
  the correct one is not throttled mid-login. Defence-in-depth over the
  backend's own per-code guards (5-attempt cap + 15-min TTL + send throttle).

Tests: retry classification units updated to the new semantics; a gateway
regression guard asserts the honest three-event email flow passes under the
default policy. gateway/README.md rate-limit note updated.

The deeper gap (the net-state recovery probe is session-gated, so any real
transport failure on the session-less login/confirm screens still cannot
self-heal) is left as a known residual, deferred by the owner.
2026-07-13 22:48:59 +02:00

452 lines
19 KiB
Go

// Package config loads and validates the gateway's runtime configuration from
// the process environment. Every variable is prefixed GATEWAY_.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"scrabble/gateway/internal/clientver"
pkgtel "scrabble/pkg/telemetry"
)
// Config holds the gateway's runtime configuration.
type Config struct {
// HTTPAddr is the public Connect/h2c listener address (host:port). It also
// serves the admin console at /_gm when admin credentials are configured.
HTTPAddr string
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
LogLevel string
// BackendHTTPURL is the base URL of the backend REST API (gateway -> backend).
BackendHTTPURL string
// BackendGRPCAddr is the backend push gRPC address the gateway subscribes to.
BackendGRPCAddr string
// BackendTimeout bounds a single backend REST call.
BackendTimeout time.Duration
// AdminUser and AdminPassword are the Basic-Auth credentials the gateway
// checks before proxying admin traffic to the backend. Empty disables admin.
AdminUser string
AdminPassword string
// ValidatorAddr is the gRPC address of the Telegram validator side-service (home,
// plaintext, internal). The gateway calls it to validate Mini App initData and
// Login Widget data. Empty disables the telegram auth path.
ValidatorAddr string
// VKAppSecret is the VK Mini App protected ("secure") key. The gateway verifies the
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
VKAppSecret string
// VKID configures the VK ID web login used to link a VK identity from a browser
// (the confidential OAuth 2.1 code exchange against id.vk.com). It belongs to a
// separate VK "Web" app from VKAppSecret's Mini App, so its credentials are distinct.
// Any field empty disables the VK web-link ops (link.vk.*).
VKID VKIDConfig
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
BotLink BotLinkConfig
// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
// caps the number of cached sessions.
SessionTTL time.Duration
SessionCacheMax int
// PushHeartbeatInterval is the idle keep-alive cadence on a client live stream.
PushHeartbeatInterval time.Duration
// MaxBodyBytes caps one inbound request body on the public listener and one
// Connect message read; oversized requests are refused without buffering.
MaxBodyBytes int
// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
// the edge serves. A client reporting an older X-Client-Version is turned away with an
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
// (every client is served) — the default for web-only deployments.
MinClientVersion string
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
RecommendedClientVersion string
// RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
Abuse AbuseConfig
// Blocklist configures the community IP blocklist (Spamhaus DROP) enforced at the edge (prod-only).
Blocklist BlocklistConfig
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
Telemetry pkgtel.Config
}
// BotLinkConfig configures the gateway's reverse bot-link: the mTLS listener the
// remote Telegram bot dials, the plaintext listener the backend admin relay calls,
// and the mTLS material. The main host is already public, so exposing the bot-link
// listener on a dedicated port adds no static IP; the channel is guarded solely by
// mTLS (the bot has no fixed address to allow-list).
type BotLinkConfig struct {
// Addr is the mTLS gRPC listener the bot dials (e.g. ":9443"). Empty disables
// the whole bot channel.
Addr string
// RelayAddr is the plaintext internal gRPC listener that serves the backend
// admin SendToUser/SendToGameChannel relay (e.g. ":9092"). Empty disables it.
RelayAddr string
// CertFile, KeyFile and CAFile are the gateway server certificate, its key and
// the CA bundle that signs the accepted bot client certificates. Required when
// Addr is set.
CertFile string
KeyFile string
CAFile string
// SendTimeout bounds the admin relay's wait for the bot Ack before reporting
// the send as not delivered.
SendTimeout time.Duration
}
// RateLimitConfig holds the token-bucket limits per class. Public and admin are
// keyed per client IP; the authenticated class is keyed per user id; the email
// sub-limit guards the costly email-code path per IP.
type RateLimitConfig struct {
PublicPerMinute int
PublicBurst int
UserPerMinute int
UserBurst int
AdminPerMinute int
AdminBurst int
EmailPer10Min int
EmailBurst int
}
// AbuseConfig configures the gateway's temporary IP ban (fail2ban-style) and the
// honeytoken trap. BanEnabled gates the ban action and is off by default: it is
// only safe where the real client IP is visible (i.e. in prod, not behind the
// shared-NAT test contour). Detection of honeypot/honeytoken hits is logged
// regardless of BanEnabled — only the ban action is gated.
type AbuseConfig struct {
// BanEnabled turns the IP ban on. Off by default (prod-only).
BanEnabled bool
// BanThreshold is the rate-limiter rejection count within BanWindow that bans
// a client IP.
BanThreshold int
// BanWindow is the rolling window the rejection strikes accumulate over.
BanWindow time.Duration
// BanDuration is the length of a rejection-earned ban (tripwire and honeytoken
// bans use their own, longer, fixed durations).
BanDuration time.Duration
// Honeytoken, when non-empty, is a planted bearer value: presenting it bans the
// caller and raises a high-severity alarm. Empty disables the trap.
Honeytoken string
}
// Defaults applied when the corresponding environment variable is unset.
const (
defaultAbuseBanThreshold = 100
defaultAbuseBanWindow = 2 * time.Minute
defaultAbuseBanDuration = 15 * time.Minute
defaultHTTPAddr = ":8081"
defaultLogLevel = "info"
defaultBackendHTTPURL = "http://localhost:8080"
defaultBackendGRPCAddr = "localhost:9090"
defaultBackendTimeout = 5 * time.Second
defaultSessionTTL = 10 * time.Minute
defaultSessionCacheMax = 50000
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout
defaultServiceName = "scrabble-gateway"
defaultBotLinkSendTimeout = 5 * time.Second
)
// DefaultMaxBodyBytes is the default request-body cap (GATEWAY_MAX_BODY_BYTES):
// 1 MiB — far above any legitimate edge payload (drafts and chat are a few KB)
// yet small enough to stop a cheap memory-amplification upload.
const DefaultMaxBodyBytes = 1 << 20
// DefaultRateLimit returns the built-in anti-abuse limits.
func DefaultRateLimit() RateLimitConfig {
return RateLimitConfig{
PublicPerMinute: 30, PublicBurst: 10,
// Per-user (not per-IP): one user may run several devices, each holding a
// Subscribe stream and reloading state on every live event, so the authenticated
// budget is generous (a per-user cap cannot DoS the service). It is raised
// because multi-device play tripped the old 120/40.
UserPerMinute: 300, UserBurst: 80,
AdminPerMinute: 60, AdminBurst: 20,
// Email-code path (per IP), defence-in-depth over the backend's own per-code guards
// (a 5-attempt cap + 15-min TTL + per-recipient send throttle). Burst 4 so the honest flow
// — request a code, mistype once or twice, then enter the right one — is not throttled
// mid-login: a request + wrong-code + right-code sequence exhausted the old burst of 2 and
// tripped the limit on the correct code, which the client mis-read as going offline.
EmailPer10Min: 5, EmailBurst: 4,
}
}
// DefaultAbuse returns the built-in anti-abuse settings: the ban disabled
// (prod-only) with the agreed thresholds, and no honeytoken.
func DefaultAbuse() AbuseConfig {
return AbuseConfig{
BanEnabled: false,
BanThreshold: defaultAbuseBanThreshold,
BanWindow: defaultAbuseBanWindow,
BanDuration: defaultAbuseBanDuration,
}
}
// BlocklistConfig configures the community IP blocklist (a curated CIDR feed such as Spamhaus DROP)
// enforced at the edge alongside the fail2ban ban. Disabled by default and prod-only, for the same
// reason as the ban: it is only safe where the real client IP is visible (not behind the shared-NAT
// test contour). Only IPv4 is matched.
type BlocklistConfig struct {
// Enabled turns edge blocklisting on. Off by default (prod-only).
Enabled bool
// URL is the CIDR feed to fetch (e.g. the Spamhaus DROP list). Required when Enabled; the
// operator sets it explicitly so no feed URL is assumed.
URL string
// Refresh is how often the feed is re-fetched.
Refresh time.Duration
// MaxStaleness is how long a stale feed (no successful refresh) is tolerated before it is dropped
// (fail-open — a legitimate client is never blocked on a frozen feed).
MaxStaleness time.Duration
// Allow is the never-block set: CIDRs or bare IPs (own infrastructure, monitoring, known-good) that
// the feed can never block. Parsed at startup.
Allow []string
}
// Blocklist defaults; the feed URL has no default (the operator sets it).
const (
defaultBlocklistRefresh = 6 * time.Hour
defaultBlocklistMaxStaleness = 48 * time.Hour
)
// DefaultBlocklist returns the built-in blocklist settings: disabled (prod-only), no feed URL, and
// the agreed refresh / staleness windows.
func DefaultBlocklist() BlocklistConfig {
return BlocklistConfig{
Enabled: false,
Refresh: defaultBlocklistRefresh,
MaxStaleness: defaultBlocklistMaxStaleness,
}
}
// VKIDConfig holds the VK ID web-login credentials for the confidential
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
// with the app and the one the frontend uses. All three are required to enable the flow.
type VKIDConfig struct {
AppID string
ClientSecret string
RedirectURI string
}
// Enabled reports whether VK ID web login is fully configured. When false the gateway
// leaves the VK web-link ops (link.vk.*) unregistered.
func (c VKIDConfig) Enabled() bool {
return c.AppID != "" && c.ClientSecret != "" && c.RedirectURI != ""
}
// Load reads the configuration from the environment, applies defaults, and
// validates the result.
func Load() (Config, error) {
var err error
c := Config{
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
RedirectURI: os.Getenv("GATEWAY_VK_ID_REDIRECT_URL"),
},
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(),
Blocklist: DefaultBlocklist(),
BotLink: BotLinkConfig{
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
CertFile: os.Getenv("GATEWAY_BOTLINK_TLS_CERT"),
KeyFile: os.Getenv("GATEWAY_BOTLINK_TLS_KEY"),
CAFile: os.Getenv("GATEWAY_BOTLINK_TLS_CA"),
},
}
tel := pkgtel.DefaultConfig(defaultServiceName)
tel.ServiceName = envOr("GATEWAY_SERVICE_NAME", tel.ServiceName)
tel.TracesExporter = envOr("GATEWAY_OTEL_TRACES_EXPORTER", tel.TracesExporter)
tel.MetricsExporter = envOr("GATEWAY_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
c.Telemetry = tel
if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil {
return Config{}, err
}
if c.SessionTTL, err = envDuration("GATEWAY_SESSION_TTL", defaultSessionTTL); err != nil {
return Config{}, err
}
if c.SessionCacheMax, err = envInt("GATEWAY_SESSION_CACHE_MAX", defaultSessionCacheMax); err != nil {
return Config{}, err
}
if c.PushHeartbeatInterval, err = envDuration("GATEWAY_PUSH_HEARTBEAT_INTERVAL", defaultPushHeartbeatInterval); err != nil {
return Config{}, err
}
if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil {
return Config{}, err
}
c.Abuse.Honeytoken = os.Getenv("GATEWAY_HONEYTOKEN")
if c.Abuse.BanEnabled, err = envBool("GATEWAY_ABUSE_BAN_ENABLED", c.Abuse.BanEnabled); err != nil {
return Config{}, err
}
if c.Abuse.BanThreshold, err = envInt("GATEWAY_ABUSE_BAN_THRESHOLD", c.Abuse.BanThreshold); err != nil {
return Config{}, err
}
if c.Abuse.BanWindow, err = envDuration("GATEWAY_ABUSE_BAN_WINDOW", c.Abuse.BanWindow); err != nil {
return Config{}, err
}
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
return Config{}, err
}
if c.Blocklist.Enabled, err = envBool("GATEWAY_BLOCKLIST_ENABLED", c.Blocklist.Enabled); err != nil {
return Config{}, err
}
c.Blocklist.URL = os.Getenv("GATEWAY_BLOCKLIST_URL")
if c.Blocklist.Refresh, err = envDuration("GATEWAY_BLOCKLIST_REFRESH", c.Blocklist.Refresh); err != nil {
return Config{}, err
}
if c.Blocklist.MaxStaleness, err = envDuration("GATEWAY_BLOCKLIST_MAX_STALENESS", c.Blocklist.MaxStaleness); err != nil {
return Config{}, err
}
c.Blocklist.Allow = splitList(os.Getenv("GATEWAY_BLOCKLIST_ALLOW"))
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
return Config{}, err
}
if err := c.validate(); err != nil {
return Config{}, err
}
return c, nil
}
// BotLinkEnabled reports whether the reverse bot-link channel is configured.
func (c Config) BotLinkEnabled() bool { return c.BotLink.Addr != "" }
// AdminEnabled reports whether the admin console proxy should be mounted (both
// Basic-Auth credentials are configured).
func (c Config) AdminEnabled() bool {
return c.AdminUser != "" && c.AdminPassword != ""
}
// validate reports whether the configuration values are acceptable.
func (c Config) validate() error {
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("config: invalid GATEWAY_LOG_LEVEL %q", c.LogLevel)
}
if c.HTTPAddr == "" {
return fmt.Errorf("config: GATEWAY_HTTP_ADDR must not be empty")
}
if c.BackendHTTPURL == "" {
return fmt.Errorf("config: GATEWAY_BACKEND_HTTP_URL must not be empty")
}
if c.BackendGRPCAddr == "" {
return fmt.Errorf("config: GATEWAY_BACKEND_GRPC_ADDR must not be empty")
}
if c.MaxBodyBytes <= 0 {
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
}
if c.MinClientVersion != "" {
if _, ok := clientver.Parse(c.MinClientVersion); !ok {
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
}
}
if c.RecommendedClientVersion != "" {
rec, ok := clientver.Parse(c.RecommendedClientVersion)
if !ok {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
}
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
// misconfiguration (every client below min is already turned away). An empty/unparseable
// minimum imposes no lower bound, so the recommended may stand alone.
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
}
}
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
}
if c.Blocklist.Enabled && (c.Blocklist.URL == "" || c.Blocklist.Refresh <= 0 || c.Blocklist.MaxStaleness <= 0) {
return fmt.Errorf("config: GATEWAY_BLOCKLIST_URL must be set and _REFRESH/_MAX_STALENESS positive when GATEWAY_BLOCKLIST_ENABLED")
}
if c.BotLink.Addr != "" {
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
}
}
if err := c.Telemetry.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
return nil
}
// envOr returns the value of the environment variable named key, or fallback
// when the variable is unset or empty.
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// splitList splits a comma-separated environment value into trimmed, non-empty items.
func splitList(v string) []string {
if v == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// envBool parses the environment variable named key as a bool, returning fallback
// when it is unset and an error when it is set but malformed.
func envBool(key string, fallback bool) (bool, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("config: %s: %w", key, err)
}
return b, nil
}
// envInt parses the environment variable named key as an int, returning fallback
// when it is unset and an error when it is set but malformed.
func envInt(key string, fallback int) (int, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return n, nil
}
// envDuration parses the environment variable named key as a Go duration,
// returning fallback when it is unset and an error when it is set but malformed.
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("config: %s: %w", key, err)
}
return d, nil
}