Files
scrabble-game/gateway/internal/config/config.go
T
Ilia Denisov a57fd355ba feat(gateway,ui): client-version gate — turn away too-old builds
Introduce a minimum-supported-client gate so a future incompatible wire change
can turn away installed builds too old to speak it, cleanly, instead of letting
them crash on decode. It rides the outermost stable layer (an HTTP header), never
the FlatBuffers payload.

Gateway:
- New internal/clientver: dependency-free parse + compare of the leading
  MAJOR.MINOR.PATCH (a git-describe suffix is tolerated).
- GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load).
- connectsrv checks the X-Client-Version header before decoding the payload:
  Execute returns result_code="update_required" (before the registry lookup),
  Subscribe returns FailedPrecondition. It fails open on an absent or garbled
  header — the header is a client-controlled compatibility signal, not an access
  control.

Client:
- Attach X-Client-Version on every call.
- A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native
  opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the
  update_required sentinel; a mock __update hook drives the e2e.

Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate
stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK /
Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the
offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate
tests, retry.test.ts, Playwright update.spec.ts.
2026-07-12 15:47:41 +02:00

429 lines
17 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
// 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"),
MinClientVersion: os.Getenv("GATEWAY_MIN_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.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
}