// 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" 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 // 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, EmailPer10Min: 5, EmailBurst: 2, } } // 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"), 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.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 }