// Package config loads and validates the backend's runtime configuration from // the process environment. package config import ( "fmt" "net/url" "os" "strconv" "time" "scrabble/backend/internal/account" "scrabble/backend/internal/game" "scrabble/backend/internal/lobby" "scrabble/backend/internal/postgres" "scrabble/backend/internal/ratewatch" "scrabble/backend/internal/robokassa" "scrabble/backend/internal/robot" "scrabble/backend/internal/telemetry" ) // Config holds the backend's runtime configuration. type Config struct { // HTTPAddr is the listen address of the HTTP listener (host:port). HTTPAddr string // GRPCAddr is the listen address of the gRPC push listener (host:port) that // streams live events to the gateway. GRPCAddr string // LogLevel is the zap log level: "debug", "info", "warn" or "error". LogLevel string // Postgres configures the primary database pool. Postgres postgres.Config // Telemetry configures the OpenTelemetry providers. Telemetry telemetry.Config // Game configures the game subsystem (dictionaries, sweeper, live-game cache). Game game.Config // Lobby configures matchmaking robot substitution (wait window, reaper cadence). Lobby lobby.Config // Robot configures the robot opponent driver (scan cadence). Robot robot.Config // RateWatch tunes the conservative high-rate auto-flag applied to the // gateway's rate-limiter rejection reports. RateWatch ratewatch.Config // SMTP configures the email relay used for confirm-codes. An empty Host // selects the development log mailer (the code is logged, not sent). SMTP account.SMTPConfig // PublicBaseURL is the canonical public origin (scheme + host, e.g. // https://erudit-game.ru) used to build absolute links in outgoing email — the // confirm deeplink and the footer landing link. It is deliberately not derived // from a request Host header, which would let an attacker inject a phishing link // into the email. Required whenever an SMTP relay is configured. PublicBaseURL string // ConnectorAddr is the gRPC address of the Telegram platform connector // side-service, used by the admin console to send operator broadcasts. Empty // disables broadcasts (the admin broadcast actions report "not configured"). ConnectorAddr string // GuestReapInterval is the cadence of the abandoned-guest reaper sweep. GuestReapInterval time.Duration // GuestRetention is the account age past which an unused guest (no game seat) // is eligible for deletion by the reaper. GuestRetention time.Duration // ExportSignKey signs the finished-game export download URLs. Empty leaves // the export-URL endpoints disabled (503 on mint, 404 on download). ExportSignKey string // RendererURL is the base URL of the internal image-render sidecar (e.g. // http://renderer:8090). Empty disables the PNG export artifact. RendererURL string // Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel // (D42). An empty set leaves the direct order and Result-callback endpoints unregistered. Robokassa robokassa.Shops } // Defaults applied when the corresponding environment variable is unset. const ( defaultHTTPAddr = ":8080" defaultGRPCAddr = ":9090" defaultLogLevel = "info" defaultGuestReapInterval = time.Hour defaultGuestRetention = 30 * 24 * time.Hour ) // Load reads the configuration from the environment, applies defaults for unset // variables, and validates the result. func Load() (Config, error) { pg := postgres.DefaultConfig() pg.DSN = os.Getenv("BACKEND_POSTGRES_DSN") var err error if pg.MaxOpenConns, err = envInt("BACKEND_POSTGRES_MAX_OPEN_CONNS", pg.MaxOpenConns); err != nil { return Config{}, err } if pg.MaxIdleConns, err = envInt("BACKEND_POSTGRES_MAX_IDLE_CONNS", pg.MaxIdleConns); err != nil { return Config{}, err } if pg.ConnMaxLifetime, err = envDuration("BACKEND_POSTGRES_CONN_MAX_LIFETIME", pg.ConnMaxLifetime); err != nil { return Config{}, err } if pg.OperationTimeout, err = envDuration("BACKEND_POSTGRES_OPERATION_TIMEOUT", pg.OperationTimeout); err != nil { return Config{}, err } tel := telemetry.DefaultConfig() tel.ServiceName = envOr("BACKEND_SERVICE_NAME", tel.ServiceName) tel.TracesExporter = envOr("BACKEND_OTEL_TRACES_EXPORTER", tel.TracesExporter) tel.MetricsExporter = envOr("BACKEND_OTEL_METRICS_EXPORTER", tel.MetricsExporter) gm := game.DefaultConfig() gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir) gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion) if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil { return Config{}, err } if gm.CacheTTL, err = envDuration("BACKEND_GAME_CACHE_TTL", gm.CacheTTL); err != nil { return Config{}, err } lb := lobby.DefaultConfig() if lb.RobotWait, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT", lb.RobotWait); err != nil { return Config{}, err } if lb.RobotWaitJitter, err = envDuration("BACKEND_LOBBY_ROBOT_WAIT_JITTER", lb.RobotWaitJitter); err != nil { return Config{}, err } if lb.ReaperInterval, err = envDuration("BACKEND_LOBBY_REAPER_INTERVAL", lb.ReaperInterval); err != nil { return Config{}, err } rb := robot.DefaultConfig() if rb.DriveInterval, err = envDuration("BACKEND_ROBOT_DRIVE_INTERVAL", rb.DriveInterval); err != nil { return Config{}, err } rw := ratewatch.DefaultConfig() if rw.FlagThreshold, err = envInt("BACKEND_HIGHRATE_FLAG_THRESHOLD", rw.FlagThreshold); err != nil { return Config{}, err } if rw.FlagWindow, err = envDuration("BACKEND_HIGHRATE_FLAG_WINDOW", rw.FlagWindow); err != nil { return Config{}, err } guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval) if err != nil { return Config{}, err } guestRetention, err := envDuration("BACKEND_GUEST_RETENTION", defaultGuestRetention) if err != nil { return Config{}, err } smtp := account.SMTPConfig{ Host: os.Getenv("BACKEND_SMTP_HOST"), Port: envOr("BACKEND_SMTP_PORT", "587"), Username: os.Getenv("BACKEND_SMTP_USERNAME"), Password: os.Getenv("BACKEND_SMTP_PASSWORD"), From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"), TLS: os.Getenv("BACKEND_SMTP_TLS"), AdminFrom: os.Getenv("BACKEND_SMTP_ADMIN_FROM"), AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), } // Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed // the web channel so existing deploys keep working; the per-channel vars add the rest. A shop // with no MerchantLogin is dropped (the rail stays dormant when none is configured). shops := robokassa.Shops{} web := robokassaShop("BACKEND_ROBOKASSA_WEB") if web.MerchantLogin == "" { web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel } if web.MerchantLogin != "" { shops[robokassa.ChannelWeb] = web } if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" { shops[robokassa.ChannelAndroid] = android } c := Config{ HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr), GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr), LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel), Postgres: pg, Telemetry: tel, Game: gm, Lobby: lb, Robot: rb, RateWatch: rw, SMTP: smtp, PublicBaseURL: os.Getenv("BACKEND_PUBLIC_BASE_URL"), ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"), GuestReapInterval: guestReapInterval, GuestRetention: guestRetention, ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"), RendererURL: os.Getenv("BACKEND_RENDERER_URL"), Robokassa: shops, } if err := c.validate(); err != nil { return Config{}, err } return c, nil } // 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 BACKEND_LOG_LEVEL %q", c.LogLevel) } if c.HTTPAddr == "" { return fmt.Errorf("config: BACKEND_HTTP_ADDR must not be empty") } if c.GRPCAddr == "" { return fmt.Errorf("config: BACKEND_GRPC_ADDR must not be empty") } if err := c.Postgres.Validate(); err != nil { return fmt.Errorf("config: %w (set BACKEND_POSTGRES_DSN)", err) } if err := c.Telemetry.Validate(); err != nil { return fmt.Errorf("config: %w", err) } if err := c.Game.Validate(); err != nil { return fmt.Errorf("config: %w (set BACKEND_DICT_DIR)", err) } if err := c.Lobby.Validate(); err != nil { return fmt.Errorf("config: %w", err) } if err := c.Robot.Validate(); err != nil { return fmt.Errorf("config: %w", err) } if err := c.RateWatch.Validate(); err != nil { return fmt.Errorf("config: %w", err) } if c.GuestReapInterval <= 0 { return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive") } if c.GuestRetention <= 0 { return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive") } if c.SMTP.Host != "" { if c.PublicBaseURL == "" { return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL must be set when BACKEND_SMTP_HOST is configured") } if u, err := url.Parse(c.PublicBaseURL); err != nil || u.Scheme == "" || u.Host == "" { return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL) } } for channel, shop := range c.Robokassa { if shop.Password1 == "" || shop.Password2 == "" { return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel) } } 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 } // 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 } // robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g. // "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing // MerchantLogin yields a zero Config the caller drops. func robokassaShop(prefix string) robokassa.Config { return robokassa.Config{ MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"), Password1: os.Getenv(prefix + "_PASSWORD1"), Password2: os.Getenv(prefix + "_PASSWORD2"), IsTest: os.Getenv(prefix+"_TEST") == "1", } }