Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
New public ingress and the first network edge. Framework + a vertical slice of operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7. Contracts (new module scrabble/pkg): - push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen). Backend: - REST handlers on the /api/v1 groups: internal session endpoints (telegram/guest/email login -> mint, resolve, revoke) and the user slice (profile, submit_play, state, lobby enqueue/poll, chat). - internal/notify in-process Publisher hub + internal/pushgrpc gRPC server (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found; emission in game.commit, social, matchmaker. - migration 00005 accounts.is_guest; guests are durable rows excluded from stats; ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode). Gateway (new module scrabble/gateway): - Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON transcode registry, Telegram initData HMAC validator (seam), session cache, token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push gRPC client, admin Basic-Auth reverse proxy. go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/** path filters; unit build/vet/test span all three modules. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests + guest/email-login integration tests.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Config holds the gateway's runtime configuration.
|
||||
type Config struct {
|
||||
// HTTPAddr is the public Connect/h2c listener address (host:port).
|
||||
HTTPAddr string
|
||||
// AdminAddr is the admin reverse-proxy listener address. Admin is enabled only
|
||||
// when AdminUser and AdminPassword are also set.
|
||||
AdminAddr 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
|
||||
// TelegramBotToken is the secret used to validate Telegram initData HMACs.
|
||||
// Empty disables the telegram auth path.
|
||||
TelegramBotToken string
|
||||
// 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
|
||||
// RateLimit configures the in-memory anti-abuse limiter.
|
||||
RateLimit RateLimitConfig
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Defaults applied when the corresponding environment variable is unset.
|
||||
const (
|
||||
defaultHTTPAddr = ":8081"
|
||||
defaultAdminAddr = ":8082"
|
||||
defaultLogLevel = "info"
|
||||
defaultBackendHTTPURL = "http://localhost:8080"
|
||||
defaultBackendGRPCAddr = "localhost:9090"
|
||||
defaultBackendTimeout = 5 * time.Second
|
||||
defaultSessionTTL = 10 * time.Minute
|
||||
defaultSessionCacheMax = 50000
|
||||
defaultPushHeartbeatInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// DefaultRateLimit returns the built-in anti-abuse limits.
|
||||
func DefaultRateLimit() RateLimitConfig {
|
||||
return RateLimitConfig{
|
||||
PublicPerMinute: 30, PublicBurst: 10,
|
||||
UserPerMinute: 120, UserBurst: 40,
|
||||
AdminPerMinute: 60, AdminBurst: 20,
|
||||
EmailPer10Min: 5, EmailBurst: 2,
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr),
|
||||
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"),
|
||||
TelegramBotToken: os.Getenv("GATEWAY_TELEGRAM_BOT_TOKEN"),
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
}
|
||||
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 err := c.validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// AdminEnabled reports whether the admin proxy should be served (an address and
|
||||
// both Basic-Auth credentials are configured).
|
||||
func (c Config) AdminEnabled() bool {
|
||||
return c.AdminAddr != "" && 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user