// 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" "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 // 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 // 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, } } // 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"), SessionCacheMax: defaultSessionCacheMax, RateLimit: DefaultRateLimit(), Abuse: DefaultAbuse(), 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.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.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 } // 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 }