2c465c01d2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.
- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
transcode link.vk.confirm/merge (registered only when configured) + config
GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
boot callback handling; a merge re-authorizes for a fresh code (VK codes are
single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
348 lines
14 KiB
Go
348 lines
14 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"
|
|
"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
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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(),
|
|
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
|
|
}
|