Files
galaxy-game/gateway/internal/config/config.go
T
Ilia Denisov 14b65389ef
Tests · UI / test (push) Successful in 2m35s
Tests · Go / test (push) Successful in 1m56s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m42s
Tests · Go / test (pull_request) Successful in 2m0s
feat(gateway): unsigned gateway.heartbeat keeps Safari push streams alive
Browser fetch-streaming layers close response bodies they consider
idle after roughly 15-30 s without incoming bytes. Safari is the
most aggressive, but the symptom matters everywhere: a quiet
SubscribeEvents stream (lobby, between turns, mailbox empty) gets
torn down by the browser, the EventStream singleton reconnects with
backoff, and any push event that fires inside the reconnect window
is lost because `push.Hub` queues are not persisted across
subscription closes. The user-visible failure mode is the
intermittent "Fetch API cannot load … due to access control checks"
console error (a misleading WebKit symptom — CORS headers are
actually present) plus missed turn-ready / mail-received toasts.

Server-side fix: a silence-based heartbeat at the
`authenticatedPushStreamService` wrapper layer. After the signed
`gateway.server_time` bootstrap event, gateway wraps the bound
stream with `heartbeatingStream`. Every tail Send (fan-out, future
variants) resets the silence timer; when the timer elapses, a
goroutine emits `gateway.heartbeat` with only `EventType` set —
everything else stays at proto3 defaults, so the wire frame is
~45 bytes amortised. A `sendMu` serialises the heartbeat goroutine
with tail Sends because grpc.ServerStream.Send is not goroutine-safe.

The heartbeat is intentionally UNSIGNED: heartbeats carry no
payload, dispatch to no handler on the client, and an injected
heartbeat trivially causes no user-visible state change. TLS still
protects the wire and real events keep the signed envelope
unchanged. Documented in `docs/ARCHITECTURE.md` § 15 alongside the
per-scale bandwidth projection (100…100 000 clients × 15…60 s).

Config: new `GATEWAY_PUSH_HEARTBEAT_INTERVAL` (default `15s`,
`0s` disables). Telemetry: new
`gateway.push.heartbeats_sent{outcome}` counter so operators can
budget bandwidth and spot a sudden `outcome=error` bump as an
upstream-failing-before-flush signal.

Client (`ui/frontend/src/api/events.svelte.ts`): early `continue`
on `event.eventType === "gateway.heartbeat"` before `verifyEvent`,
`verifyPayloadHash`, or dispatch — empty signature would otherwise
trip SignatureError and reconnect. A leading heartbeat still flips
`connectionStatus` to `connected` and resets backoff, because
receiving one is proof the stream is healthy.

Tests:
- `push_heartbeat_test.go`: unit tests for the wrapper — zero
  interval returns nil, heartbeat fires after silence, real Send
  resets the timer, Stop / context-cancel halt the goroutine,
  Send errors propagate.
- `server_test.go`: integration tests through the full gateway
  pipeline — heartbeat fires after the configured silence window,
  zero interval keeps the stream silent.
- `config_test.go`: default applied, env-override parsed,
  negative value rejected.
- `events.test.ts`: heartbeat skipped before verification + not
  dispatched to handlers; leading heartbeat still flips
  `connectionStatus` to `connected`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:29:29 +02:00

1401 lines
56 KiB
Go

// Package config loads process-level gateway configuration from environment
// variables.
package config
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
"galaxy/redisconn"
)
const gatewayRedisEnvPrefix = "GATEWAY"
const (
// shutdownTimeoutEnvVar names the environment variable that controls the
// maximum time granted to each component shutdown call.
shutdownTimeoutEnvVar = "GATEWAY_SHUTDOWN_TIMEOUT"
// logLevelEnvVar names the environment variable that configures the process
// log level used by structured JSON logging.
logLevelEnvVar = "GATEWAY_LOG_LEVEL"
// publicHTTPAddrEnvVar names the environment variable that configures the
// public REST listener address.
publicHTTPAddrEnvVar = "GATEWAY_PUBLIC_HTTP_ADDR"
// publicHTTPReadHeaderTimeoutEnvVar names the environment variable that
// configures the maximum time allowed to read public REST request headers.
publicHTTPReadHeaderTimeoutEnvVar = "GATEWAY_PUBLIC_HTTP_READ_HEADER_TIMEOUT"
// publicHTTPReadTimeoutEnvVar names the environment variable that configures
// the maximum time allowed to read the full public REST request.
publicHTTPReadTimeoutEnvVar = "GATEWAY_PUBLIC_HTTP_READ_TIMEOUT"
// publicHTTPIdleTimeoutEnvVar names the environment variable that configures
// the keep-alive idle timeout for the public REST listener.
publicHTTPIdleTimeoutEnvVar = "GATEWAY_PUBLIC_HTTP_IDLE_TIMEOUT"
// publicHTTPCORSAllowedOriginsEnvVar names the environment variable that
// configures the comma-separated list of browser origins permitted to
// call the public REST surface. An empty value disables CORS entirely;
// requests without an Origin header still pass through normally.
publicHTTPCORSAllowedOriginsEnvVar = "GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS"
// publicAuthUpstreamTimeoutEnvVar names the environment variable that
// configures the timeout budget used for public auth upstream calls.
publicAuthUpstreamTimeoutEnvVar = "GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT"
// backendHTTPURLEnvVar names the environment variable that configures
// the absolute base URL of the consolidated backend HTTP listener used
// for public auth, internal session lookup, and authenticated user /
// lobby commands.
backendHTTPURLEnvVar = "GATEWAY_BACKEND_HTTP_URL"
// backendGRPCPushURLEnvVar names the environment variable that
// configures the dial target of backend's gRPC `Push.SubscribePush`
// listener.
backendGRPCPushURLEnvVar = "GATEWAY_BACKEND_GRPC_PUSH_URL"
// backendGatewayClientIDEnvVar names the environment variable that
// configures the durable identifier this gateway instance presents to
// backend in `GatewaySubscribeRequest.gateway_client_id`.
backendGatewayClientIDEnvVar = "GATEWAY_BACKEND_GATEWAY_CLIENT_ID"
// backendHTTPTimeoutEnvVar names the environment variable that
// configures the per-call timeout applied to backend HTTP requests.
backendHTTPTimeoutEnvVar = "GATEWAY_BACKEND_HTTP_TIMEOUT"
// backendPushReconnectBaseBackoffEnvVar names the environment variable
// that configures the starting delay between reconnect attempts of the
// gRPC SubscribePush stream.
backendPushReconnectBaseBackoffEnvVar = "GATEWAY_BACKEND_PUSH_RECONNECT_BASE_BACKOFF"
// backendPushReconnectMaxBackoffEnvVar names the environment variable
// that configures the upper bound for exponential reconnect delays.
backendPushReconnectMaxBackoffEnvVar = "GATEWAY_BACKEND_PUSH_RECONNECT_MAX_BACKOFF"
// adminHTTPAddrEnvVar names the environment variable that configures the
// private admin HTTP listener address. When it is empty, the admin listener
// remains disabled.
adminHTTPAddrEnvVar = "GATEWAY_ADMIN_HTTP_ADDR"
// adminHTTPReadHeaderTimeoutEnvVar names the environment variable that
// configures the maximum time allowed to read admin listener request
// headers.
adminHTTPReadHeaderTimeoutEnvVar = "GATEWAY_ADMIN_HTTP_READ_HEADER_TIMEOUT"
// adminHTTPReadTimeoutEnvVar names the environment variable that configures
// the maximum time allowed to read one admin listener request.
adminHTTPReadTimeoutEnvVar = "GATEWAY_ADMIN_HTTP_READ_TIMEOUT"
// adminHTTPIdleTimeoutEnvVar names the environment variable that configures
// the keep-alive idle timeout for the admin listener.
adminHTTPIdleTimeoutEnvVar = "GATEWAY_ADMIN_HTTP_IDLE_TIMEOUT"
// authenticatedGRPCAddrEnvVar names the environment variable that configures
// the authenticated gRPC listener address.
authenticatedGRPCAddrEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ADDR"
// authenticatedGRPCCORSAllowedOriginsEnvVar names the environment
// variable that configures the comma-separated list of browser
// origins permitted to call the authenticated Connect-Web surface.
// An empty value disables CORS entirely; the listener then refuses
// to send Access-Control-* headers and browsers block cross-origin
// fetches. Set this in any deployment that fronts the gateway
// behind a different hostname than the SvelteKit bundle (e.g.
// `https://www.galaxy.lan` calling `https://api.galaxy.lan`).
authenticatedGRPCCORSAllowedOriginsEnvVar = "GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS"
// authenticatedGRPCConnectionTimeoutEnvVar names the environment variable
// that configures the inbound connection handshake timeout for the
// authenticated gRPC listener.
authenticatedGRPCConnectionTimeoutEnvVar = "GATEWAY_AUTHENTICATED_GRPC_CONNECTION_TIMEOUT"
// authenticatedGRPCDownstreamTimeoutEnvVar names the environment variable
// that configures the timeout budget used for downstream unary execution.
authenticatedGRPCDownstreamTimeoutEnvVar = "GATEWAY_AUTHENTICATED_DOWNSTREAM_TIMEOUT"
// authenticatedGRPCFreshnessWindowEnvVar names the environment variable that
// configures the accepted client timestamp skew window for authenticated
// gRPC requests.
authenticatedGRPCFreshnessWindowEnvVar = "GATEWAY_AUTHENTICATED_GRPC_FRESHNESS_WINDOW"
// pushHeartbeatIntervalEnvVar names the environment variable that
// configures the silence-based heartbeat cadence for authenticated
// push streams. The heartbeat keeps idle SubscribeEvents responses
// alive across browser fetch-streaming idle timeouts (Safari is
// notably aggressive) so push events do not disappear into the
// reconnect window. A value of `0s` disables heartbeats entirely.
pushHeartbeatIntervalEnvVar = "GATEWAY_PUSH_HEARTBEAT_INTERVAL"
// authenticatedGRPCIPRateLimitRequestsEnvVar names the environment
// variable that configures the authenticated gRPC per-IP request budget per
// window.
authenticatedGRPCIPRateLimitRequestsEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_REQUESTS"
// authenticatedGRPCIPRateLimitWindowEnvVar names the environment variable
// that configures the authenticated gRPC per-IP rate-limit window.
authenticatedGRPCIPRateLimitWindowEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_WINDOW"
// authenticatedGRPCIPRateLimitBurstEnvVar names the environment variable
// that configures the authenticated gRPC per-IP rate-limit burst.
authenticatedGRPCIPRateLimitBurstEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST"
// authenticatedGRPCSessionRateLimitRequestsEnvVar names the environment
// variable that configures the authenticated gRPC per-session request
// budget per window.
authenticatedGRPCSessionRateLimitRequestsEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS"
// authenticatedGRPCSessionRateLimitWindowEnvVar names the environment
// variable that configures the authenticated gRPC per-session rate-limit
// window.
authenticatedGRPCSessionRateLimitWindowEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_WINDOW"
// authenticatedGRPCSessionRateLimitBurstEnvVar names the environment
// variable that configures the authenticated gRPC per-session rate-limit
// burst.
authenticatedGRPCSessionRateLimitBurstEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_BURST"
// authenticatedGRPCUserRateLimitRequestsEnvVar names the environment
// variable that configures the authenticated gRPC per-user request budget
// per window.
authenticatedGRPCUserRateLimitRequestsEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_REQUESTS"
// authenticatedGRPCUserRateLimitWindowEnvVar names the environment
// variable that configures the authenticated gRPC per-user rate-limit
// window.
authenticatedGRPCUserRateLimitWindowEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_WINDOW"
// authenticatedGRPCUserRateLimitBurstEnvVar names the environment variable
// that configures the authenticated gRPC per-user rate-limit burst.
authenticatedGRPCUserRateLimitBurstEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_USER_RATE_LIMIT_BURST"
// authenticatedGRPCMessageClassRateLimitRequestsEnvVar names the
// environment variable that configures the authenticated gRPC per-message
// class request budget per window.
authenticatedGRPCMessageClassRateLimitRequestsEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_REQUESTS"
// authenticatedGRPCMessageClassRateLimitWindowEnvVar names the environment
// variable that configures the authenticated gRPC per-message-class
// rate-limit window.
authenticatedGRPCMessageClassRateLimitWindowEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_WINDOW"
// authenticatedGRPCMessageClassRateLimitBurstEnvVar names the environment
// variable that configures the authenticated gRPC per-message-class
// rate-limit burst.
authenticatedGRPCMessageClassRateLimitBurstEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST"
// sessionCacheMaxEntriesEnvVar names the environment variable that configures
// the in-memory session cache LRU bound (entries).
sessionCacheMaxEntriesEnvVar = "GATEWAY_SESSION_CACHE_MAX_ENTRIES"
// sessionCacheTTLEnvVar names the environment variable that configures the
// in-memory session cache safety-net TTL applied to every cached entry.
sessionCacheTTLEnvVar = "GATEWAY_SESSION_CACHE_TTL"
// replayRedisKeyPrefixEnvVar names the environment variable that configures
// the Redis key prefix used for authenticated replay reservations.
replayRedisKeyPrefixEnvVar = "GATEWAY_REPLAY_REDIS_KEY_PREFIX"
// replayRedisReserveTimeoutEnvVar names the environment variable that
// configures the timeout used for authenticated replay reservations and
// startup connectivity checks.
replayRedisReserveTimeoutEnvVar = "GATEWAY_REPLAY_REDIS_RESERVE_TIMEOUT"
// responseSignerPrivateKeyPEMPathEnvVar names the environment variable that
// configures the path to the PKCS#8 PEM-encoded Ed25519 private key used to
// sign authenticated unary responses and stream events.
responseSignerPrivateKeyPEMPathEnvVar = "GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH"
// publicAuthMaxBodyBytesEnvVar names the environment variable that
// configures the maximum accepted request body size for public_auth.
publicAuthMaxBodyBytesEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_MAX_BODY_BYTES"
// publicAuthRateLimitRequestsEnvVar names the environment variable that
// configures the public_auth request budget per window.
publicAuthRateLimitRequestsEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS"
// publicAuthRateLimitWindowEnvVar names the environment variable that
// configures the public_auth rate-limit window.
publicAuthRateLimitWindowEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_WINDOW"
// publicAuthRateLimitBurstEnvVar names the environment variable that
// configures the public_auth rate-limit burst.
publicAuthRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST"
// browserBootstrapMaxBodyBytesEnvVar names the environment variable that
// configures the maximum accepted request body size for browser_bootstrap.
browserBootstrapMaxBodyBytesEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_MAX_BODY_BYTES"
// browserBootstrapRateLimitRequestsEnvVar names the environment variable
// that configures the browser_bootstrap request budget per window.
browserBootstrapRateLimitRequestsEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_RATE_LIMIT_REQUESTS"
// browserBootstrapRateLimitWindowEnvVar names the environment variable that
// configures the browser_bootstrap rate-limit window.
browserBootstrapRateLimitWindowEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_RATE_LIMIT_WINDOW"
// browserBootstrapRateLimitBurstEnvVar names the environment variable that
// configures the browser_bootstrap rate-limit burst.
browserBootstrapRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_RATE_LIMIT_BURST"
// browserAssetMaxBodyBytesEnvVar names the environment variable that
// configures the maximum accepted request body size for browser_asset.
browserAssetMaxBodyBytesEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_MAX_BODY_BYTES"
// browserAssetRateLimitRequestsEnvVar names the environment variable that
// configures the browser_asset request budget per window.
browserAssetRateLimitRequestsEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_RATE_LIMIT_REQUESTS"
// browserAssetRateLimitWindowEnvVar names the environment variable that
// configures the browser_asset rate-limit window.
browserAssetRateLimitWindowEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_RATE_LIMIT_WINDOW"
// browserAssetRateLimitBurstEnvVar names the environment variable that
// configures the browser_asset rate-limit burst.
browserAssetRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_RATE_LIMIT_BURST"
// publicMiscMaxBodyBytesEnvVar names the environment variable that
// configures the maximum accepted request body size for public_misc.
publicMiscMaxBodyBytesEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_MAX_BODY_BYTES"
// publicMiscRateLimitRequestsEnvVar names the environment variable that
// configures the public_misc request budget per window.
publicMiscRateLimitRequestsEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_REQUESTS"
// publicMiscRateLimitWindowEnvVar names the environment variable that
// configures the public_misc rate-limit window.
publicMiscRateLimitWindowEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_WINDOW"
// publicMiscRateLimitBurstEnvVar names the environment variable that
// configures the public_misc rate-limit burst.
publicMiscRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST"
// sendEmailCodeIdentityRateLimitRequestsEnvVar names the environment
// variable that configures the send-email-code identity request budget per
// window.
sendEmailCodeIdentityRateLimitRequestsEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS"
// sendEmailCodeIdentityRateLimitWindowEnvVar names the environment variable
// that configures the send-email-code identity rate-limit window.
sendEmailCodeIdentityRateLimitWindowEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW"
// sendEmailCodeIdentityRateLimitBurstEnvVar names the environment variable
// that configures the send-email-code identity rate-limit burst.
sendEmailCodeIdentityRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST"
// confirmEmailCodeIdentityRateLimitRequestsEnvVar names the environment
// variable that configures the confirm-email-code identity request budget
// per window.
confirmEmailCodeIdentityRateLimitRequestsEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS"
// confirmEmailCodeIdentityRateLimitWindowEnvVar names the environment
// variable that configures the confirm-email-code identity rate-limit
// window.
confirmEmailCodeIdentityRateLimitWindowEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW"
// confirmEmailCodeIdentityRateLimitBurstEnvVar names the environment
// variable that configures the confirm-email-code identity rate-limit burst.
confirmEmailCodeIdentityRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST"
// defaultShutdownTimeout is applied when shutdownTimeoutEnvVar is absent.
defaultShutdownTimeout = 5 * time.Second
// defaultLogLevel is applied when logLevelEnvVar is absent.
defaultLogLevel = "info"
// defaultPublicHTTPAddr is applied when publicHTTPAddrEnvVar is absent.
defaultPublicHTTPAddr = ":8080"
defaultPublicHTTPReadHeaderTimeout = 2 * time.Second
defaultPublicHTTPReadTimeout = 10 * time.Second
defaultPublicHTTPIdleTimeout = time.Minute
defaultPublicAuthUpstreamTimeout = 3 * time.Second
defaultAdminHTTPReadHeaderTimeout = 2 * time.Second
defaultAdminHTTPReadTimeout = 10 * time.Second
defaultAdminHTTPIdleTimeout = time.Minute
// defaultAuthenticatedGRPCAddr is applied when
// authenticatedGRPCAddrEnvVar is absent.
defaultAuthenticatedGRPCAddr = ":9090"
defaultAuthenticatedGRPCConnectionTimeout = 5 * time.Second
defaultAuthenticatedGRPCDownstreamTimeout = 5 * time.Second
defaultAuthenticatedGRPCFreshnessWindow = 5 * time.Minute
// defaultPushHeartbeatInterval is the silence window the push stream
// keeps open before emitting `gateway.heartbeat`. 15s is comfortably
// below the empirical Safari fetch-streaming idle threshold
// (~15-30s) and well above any realistic per-event rate, so the
// timer is almost always reset by a real event in active games.
defaultPushHeartbeatInterval = 15 * time.Second
defaultAuthenticatedGRPCIPRateLimitRequests = 120
defaultAuthenticatedGRPCIPRateLimitBurst = 40
defaultAuthenticatedGRPCSessionRateLimitRequests = 60
defaultAuthenticatedGRPCSessionRateLimitBurst = 20
defaultAuthenticatedGRPCUserRateLimitRequests = 120
defaultAuthenticatedGRPCUserRateLimitBurst = 40
defaultAuthenticatedGRPCMessageClassRateLimitRequests = 60
defaultAuthenticatedGRPCMessageClassRateLimitBurst = 20
defaultSessionCacheMaxEntries = 50_000
defaultSessionCacheTTL = 10 * time.Minute
defaultReplayRedisKeyPrefix = "gateway:replay:"
defaultReplayRedisReserveTimeout = 250 * time.Millisecond
defaultBackendHTTPTimeout = 5 * time.Second
defaultBackendPushReconnectBaseBackoff = 250 * time.Millisecond
defaultBackendPushReconnectMaxBackoff = 30 * time.Second
defaultPublicAuthMaxBodyBytes = int64(8192)
defaultPublicAuthRateLimitRequests = 30
defaultPublicAuthRateLimitBurst = 10
defaultBrowserBootstrapRateLimitRequests = 60
defaultBrowserBootstrapRateLimitBurst = 20
defaultBrowserAssetRateLimitRequests = 300
defaultBrowserAssetRateLimitBurst = 80
defaultPublicMiscRateLimitRequests = 30
defaultPublicMiscRateLimitBurst = 10
defaultSendEmailCodeIdentityRateLimitRequests = 3
defaultSendEmailCodeIdentityRateLimitBurst = 1
defaultConfirmEmailCodeIdentityRateLimitRequests = 6
defaultConfirmEmailCodeIdentityRateLimitBurst = 2
)
var (
defaultClassRateLimitWindow = time.Minute
defaultIdentityRateLimitWindow = 10 * time.Minute
)
// RateLimitConfig describes a single rate-limit budget.
type RateLimitConfig struct {
// Requests is the number of accepted requests replenished per Window.
Requests int
// Window is the interval over which Requests are replenished.
Window time.Duration
// Burst is the maximum number of immediately available tokens.
Burst int
}
// PublicRateLimitConfig identifies the generic rate-limit budget shape used by
// public REST policy.
type PublicRateLimitConfig = RateLimitConfig
// AuthenticatedRateLimitConfig identifies the generic rate-limit budget shape
// used by authenticated gRPC policy.
type AuthenticatedRateLimitConfig = RateLimitConfig
// PublicRoutePolicyConfig describes the anti-abuse policy enforced for one
// stable public REST traffic class.
type PublicRoutePolicyConfig struct {
// MaxBodyBytes is the maximum accepted request body size. Zero means that
// the request must not carry a body.
MaxBodyBytes int64
// RateLimit configures the per-IP budget for the route class.
RateLimit PublicRateLimitConfig
}
// PublicAuthIdentityPolicyConfig describes the additional identity-based
// limiter applied to one public auth command.
type PublicAuthIdentityPolicyConfig struct {
// RateLimit configures the accepted request budget for one normalized public
// auth identity key.
RateLimit PublicRateLimitConfig
}
// PublicHTTPAntiAbuseConfig describes the public REST anti-abuse policy used
// before route handling.
type PublicHTTPAntiAbuseConfig struct {
// PublicAuth applies to the stable public_auth route class.
PublicAuth PublicRoutePolicyConfig
// BrowserBootstrap applies to the stable browser_bootstrap route class.
BrowserBootstrap PublicRoutePolicyConfig
// BrowserAsset applies to the stable browser_asset route class.
BrowserAsset PublicRoutePolicyConfig
// PublicMisc applies to the stable public_misc route class.
PublicMisc PublicRoutePolicyConfig
// SendEmailCodeIdentity applies the additional identity limiter for
// send-email-code.
SendEmailCodeIdentity PublicAuthIdentityPolicyConfig
// ConfirmEmailCodeIdentity applies the additional identity limiter for
// confirm-email-code.
ConfirmEmailCodeIdentity PublicAuthIdentityPolicyConfig
}
// AuthenticatedGRPCAntiAbuseConfig describes the authenticated gRPC
// rate-limit budgets enforced after request authenticity has been established.
type AuthenticatedGRPCAntiAbuseConfig struct {
// IP applies to the transport peer IP derived from the gRPC connection.
IP AuthenticatedRateLimitConfig
// Session applies to the authenticated device_session_id.
Session AuthenticatedRateLimitConfig
// User applies to the authenticated user_id resolved from SessionCache.
User AuthenticatedRateLimitConfig
// MessageClass applies to the current authenticated message class. The
// gateway uses the full message_type literal as the stable v1 class key.
MessageClass AuthenticatedRateLimitConfig
}
// PublicHTTPConfig describes the public unauthenticated REST listener exposed
// by the gateway.
type PublicHTTPConfig struct {
// Addr is the TCP listen address used by the public REST server.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading request
// headers before the gateway rejects the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one public
// request.
ReadTimeout time.Duration
// IdleTimeout bounds how long the listener keeps an idle keep-alive
// connection open.
IdleTimeout time.Duration
// AuthUpstreamTimeout bounds one public auth adapter call.
AuthUpstreamTimeout time.Duration
// AntiAbuse configures the public REST anti-abuse middleware.
AntiAbuse PublicHTTPAntiAbuseConfig
// CORSAllowedOrigins is the exact-match list of browser origins
// permitted to call the public REST surface. Empty disables CORS:
// requests without an Origin header continue to work, cross-origin
// requests are subject to the browser's default same-origin policy.
CORSAllowedOrigins []string
}
// BackendConfig describes the consolidated backend service the gateway
// talks to. Every authenticated and public HTTP request is forwarded to
// `HTTPBaseURL`; the gRPC `Push.SubscribePush` stream is opened against
// `GRPCPushURL`.
type BackendConfig struct {
// HTTPBaseURL is the absolute base URL of the backend HTTP listener
// (`/api/v1/{public,user,internal}/*`). Required.
HTTPBaseURL string
// GRPCPushURL is the dial target of the backend `Push.SubscribePush`
// listener (`host:port`). Required.
GRPCPushURL string
// GatewayClientID is the durable identifier this gateway instance
// presents to backend in `GatewaySubscribeRequest.gateway_client_id`.
// Required.
GatewayClientID string
// HTTPTimeout bounds individual REST calls. Must be positive.
HTTPTimeout time.Duration
// PushReconnectBaseBackoff is the starting delay between reconnect
// attempts of `Push.SubscribePush`. Must be positive.
PushReconnectBaseBackoff time.Duration
// PushReconnectMaxBackoff is the upper bound for exponential
// reconnect delays. Must be greater than or equal to
// PushReconnectBaseBackoff.
PushReconnectMaxBackoff time.Duration
}
// AdminHTTPConfig describes the private operational HTTP listener used for
// metrics exposure. The listener remains disabled when Addr is empty.
type AdminHTTPConfig struct {
// Addr is the TCP listen address used by the admin HTTP server. An empty
// value disables the listener.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading request
// headers before the gateway rejects the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one admin
// request.
ReadTimeout time.Duration
// IdleTimeout bounds how long the listener keeps an idle keep-alive
// connection open.
IdleTimeout time.Duration
}
// AuthenticatedGRPCConfig describes the authenticated gRPC listener exposed by
// the gateway.
type AuthenticatedGRPCConfig struct {
// Addr is the TCP listen address used by the authenticated gRPC server.
Addr string
// ConnectionTimeout bounds one inbound connection handshake.
ConnectionTimeout time.Duration
// DownstreamTimeout bounds one downstream unary execution after the request
// has passed the full authenticated ingress pipeline.
DownstreamTimeout time.Duration
// FreshnessWindow is the accepted skew window around current server time
// used for client request timestamps.
FreshnessWindow time.Duration
// PushHeartbeatInterval is the silence window after which an open
// authenticated SubscribeEvents stream sends an unsigned
// `gateway.heartbeat` event. Every real Send resets the window, so
// in busy streams the heartbeat fires rarely. A zero or negative
// value disables the heartbeat — the stream then relies on
// transport-level keepalives only, which Safari's fetch-streaming
// layer ignores. See `docs/ARCHITECTURE.md` for the security
// rationale of leaving the heartbeat unsigned.
PushHeartbeatInterval time.Duration
// AntiAbuse configures the authenticated gRPC rate limits enforced after
// the request passes the transport authenticity checks.
AntiAbuse AuthenticatedGRPCAntiAbuseConfig
// CORSAllowedOrigins is the exact-match list of browser origins
// permitted to call the authenticated Connect-Web surface. Empty
// disables CORS — requests without an Access-Control-Allow-Origin
// response will be blocked by the browser, which is the production
// posture when the UI and the gateway share a single hostname.
CORSAllowedOrigins []string
}
// SessionCacheConfig describes the bounds of the gateway's in-memory
// session cache. The cache fronts every authenticated request and
// falls back to a synchronous backend lookup on miss; push-event
// driven invalidations flip cached records to revoked status without
// a backend roundtrip.
type SessionCacheConfig struct {
// MaxEntries bounds the LRU. Zero or negative values fall back to
// the package default at construction time.
MaxEntries int
// TTL is the safety-net freshness window applied to every cached
// entry. Zero or negative values fall back to the package default.
TTL time.Duration
}
// ReplayRedisConfig describes the Redis namespace and timeout used for
// authenticated replay reservations.
type ReplayRedisConfig struct {
// KeyPrefix is prepended to every ReplayStore Redis key.
KeyPrefix string
// ReserveTimeout bounds individual ReplayStore Redis operations.
ReserveTimeout time.Duration
}
// ResponseSignerConfig describes the private-key material used to sign
// authenticated unary responses and stream events.
type ResponseSignerConfig struct {
// PrivateKeyPEMPath is the filesystem path to the PKCS#8 PEM-encoded
// Ed25519 private key loaded during startup.
PrivateKeyPEMPath string
}
// LoggingConfig describes the process-wide structured logging settings.
type LoggingConfig struct {
// Level is the configured minimum log level literal.
Level string
}
// Config describes process-wide settings required to start and stop the
// gateway safely.
type Config struct {
// ShutdownTimeout limits how long each component may spend in Shutdown
// before the gateway reports a timeout.
ShutdownTimeout time.Duration
// Logging configures the process-wide structured logger.
Logging LoggingConfig
// PublicHTTP configures the public unauthenticated REST listener.
PublicHTTP PublicHTTPConfig
// Backend configures the consolidated backend the gateway forwards
// every public auth and authenticated user/lobby request to and the
// gRPC `Push.SubscribePush` stream consumed for inbound events.
Backend BackendConfig
// AdminHTTP configures the optional private admin listener used for metrics
// exposure.
AdminHTTP AdminHTTPConfig
// AuthenticatedGRPC configures the authenticated gRPC listener.
AuthenticatedGRPC AuthenticatedGRPCConfig
// Redis carries the master/replica/password connection topology used
// by the anti-replay reservation store, sourced from the
// GATEWAY_REDIS_* environment variables managed by `pkg/redisconn`.
// The implementation dropped session cache projection and the two Redis
// Streams; Redis is now used only for replay reservations.
Redis redisconn.Config
// SessionCache configures the in-memory session cache fronting
// every authenticated request.
SessionCache SessionCacheConfig
// ReplayRedis configures the Redis-backed authenticated ReplayStore.
ReplayRedis ReplayRedisConfig
// ResponseSigner configures the authenticated response and event signer
// loaded during startup.
ResponseSigner ResponseSignerConfig
}
// DefaultPublicHTTPConfig returns the default listener and anti-abuse settings
// for the public REST surface.
func DefaultPublicHTTPConfig() PublicHTTPConfig {
return PublicHTTPConfig{
Addr: defaultPublicHTTPAddr,
ReadHeaderTimeout: defaultPublicHTTPReadHeaderTimeout,
ReadTimeout: defaultPublicHTTPReadTimeout,
IdleTimeout: defaultPublicHTTPIdleTimeout,
AuthUpstreamTimeout: defaultPublicAuthUpstreamTimeout,
AntiAbuse: PublicHTTPAntiAbuseConfig{
PublicAuth: PublicRoutePolicyConfig{
MaxBodyBytes: defaultPublicAuthMaxBodyBytes,
RateLimit: PublicRateLimitConfig{
Requests: defaultPublicAuthRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultPublicAuthRateLimitBurst,
},
},
BrowserBootstrap: PublicRoutePolicyConfig{
RateLimit: PublicRateLimitConfig{
Requests: defaultBrowserBootstrapRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultBrowserBootstrapRateLimitBurst,
},
},
BrowserAsset: PublicRoutePolicyConfig{
RateLimit: PublicRateLimitConfig{
Requests: defaultBrowserAssetRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultBrowserAssetRateLimitBurst,
},
},
PublicMisc: PublicRoutePolicyConfig{
RateLimit: PublicRateLimitConfig{
Requests: defaultPublicMiscRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultPublicMiscRateLimitBurst,
},
},
SendEmailCodeIdentity: PublicAuthIdentityPolicyConfig{
RateLimit: PublicRateLimitConfig{
Requests: defaultSendEmailCodeIdentityRateLimitRequests,
Window: defaultIdentityRateLimitWindow,
Burst: defaultSendEmailCodeIdentityRateLimitBurst,
},
},
ConfirmEmailCodeIdentity: PublicAuthIdentityPolicyConfig{
RateLimit: PublicRateLimitConfig{
Requests: defaultConfirmEmailCodeIdentityRateLimitRequests,
Window: defaultIdentityRateLimitWindow,
Burst: defaultConfirmEmailCodeIdentityRateLimitBurst,
},
},
},
}
}
// DefaultAdminHTTPConfig returns the default settings for the optional private
// admin listener. The zero address keeps the listener disabled by default.
func DefaultAdminHTTPConfig() AdminHTTPConfig {
return AdminHTTPConfig{
ReadHeaderTimeout: defaultAdminHTTPReadHeaderTimeout,
ReadTimeout: defaultAdminHTTPReadTimeout,
IdleTimeout: defaultAdminHTTPIdleTimeout,
}
}
// DefaultAuthenticatedGRPCConfig returns the default listener, freshness, and
// anti-abuse settings for the authenticated gRPC surface.
func DefaultAuthenticatedGRPCConfig() AuthenticatedGRPCConfig {
return AuthenticatedGRPCConfig{
Addr: defaultAuthenticatedGRPCAddr,
ConnectionTimeout: defaultAuthenticatedGRPCConnectionTimeout,
DownstreamTimeout: defaultAuthenticatedGRPCDownstreamTimeout,
FreshnessWindow: defaultAuthenticatedGRPCFreshnessWindow,
PushHeartbeatInterval: defaultPushHeartbeatInterval,
AntiAbuse: AuthenticatedGRPCAntiAbuseConfig{
IP: AuthenticatedRateLimitConfig{
Requests: defaultAuthenticatedGRPCIPRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultAuthenticatedGRPCIPRateLimitBurst,
},
Session: AuthenticatedRateLimitConfig{
Requests: defaultAuthenticatedGRPCSessionRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultAuthenticatedGRPCSessionRateLimitBurst,
},
User: AuthenticatedRateLimitConfig{
Requests: defaultAuthenticatedGRPCUserRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultAuthenticatedGRPCUserRateLimitBurst,
},
MessageClass: AuthenticatedRateLimitConfig{
Requests: defaultAuthenticatedGRPCMessageClassRateLimitRequests,
Window: defaultClassRateLimitWindow,
Burst: defaultAuthenticatedGRPCMessageClassRateLimitBurst,
},
},
}
}
// DefaultLoggingConfig returns the default structured logging settings.
func DefaultLoggingConfig() LoggingConfig {
return LoggingConfig{Level: defaultLogLevel}
}
// DefaultReplayRedisConfig returns the default Redis key namespace and timeout
// used for authenticated replay reservations.
func DefaultReplayRedisConfig() ReplayRedisConfig {
return ReplayRedisConfig{
KeyPrefix: defaultReplayRedisKeyPrefix,
ReserveTimeout: defaultReplayRedisReserveTimeout,
}
}
// DefaultSessionCacheConfig returns the default LRU bound and safety-net TTL
// used by the in-memory session cache.
func DefaultSessionCacheConfig() SessionCacheConfig {
return SessionCacheConfig{
MaxEntries: defaultSessionCacheMaxEntries,
TTL: defaultSessionCacheTTL,
}
}
// DefaultBackendConfig returns the default backend settings used for the
// gateway → backend HTTP and gRPC conversation. URL fields stay empty and
// must be supplied explicitly via env vars.
func DefaultBackendConfig() BackendConfig {
return BackendConfig{
HTTPTimeout: defaultBackendHTTPTimeout,
PushReconnectBaseBackoff: defaultBackendPushReconnectBaseBackoff,
PushReconnectMaxBackoff: defaultBackendPushReconnectMaxBackoff,
}
}
// DefaultResponseSignerConfig returns the default response-signer settings.
// The private key path remains empty and must be supplied explicitly.
func DefaultResponseSignerConfig() ResponseSignerConfig {
return ResponseSignerConfig{}
}
// LoadFromEnv loads Config from the process environment, applies defaults for
// omitted settings, and validates the resulting values.
func LoadFromEnv() (Config, error) {
cfg := Config{
ShutdownTimeout: defaultShutdownTimeout,
Logging: DefaultLoggingConfig(),
PublicHTTP: DefaultPublicHTTPConfig(),
Backend: DefaultBackendConfig(),
AdminHTTP: DefaultAdminHTTPConfig(),
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
Redis: redisconn.DefaultConfig(),
SessionCache: DefaultSessionCacheConfig(),
ReplayRedis: DefaultReplayRedisConfig(),
ResponseSigner: DefaultResponseSignerConfig(),
}
rawShutdownTimeout, ok := os.LookupEnv(shutdownTimeoutEnvVar)
if ok {
shutdownTimeout, err := time.ParseDuration(rawShutdownTimeout)
if err != nil {
return Config{}, fmt.Errorf("load gateway config: parse %s: %w", shutdownTimeoutEnvVar, err)
}
cfg.ShutdownTimeout = shutdownTimeout
}
rawLogLevel, ok := os.LookupEnv(logLevelEnvVar)
if ok {
cfg.Logging.Level = rawLogLevel
}
rawPublicHTTPAddr, ok := os.LookupEnv(publicHTTPAddrEnvVar)
if ok {
cfg.PublicHTTP.Addr = rawPublicHTTPAddr
}
publicHTTPReadHeaderTimeout, err := loadDurationEnvWithDefault(publicHTTPReadHeaderTimeoutEnvVar, cfg.PublicHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.ReadHeaderTimeout = publicHTTPReadHeaderTimeout
publicHTTPReadTimeout, err := loadDurationEnvWithDefault(publicHTTPReadTimeoutEnvVar, cfg.PublicHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.ReadTimeout = publicHTTPReadTimeout
publicHTTPIdleTimeout, err := loadDurationEnvWithDefault(publicHTTPIdleTimeoutEnvVar, cfg.PublicHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.IdleTimeout = publicHTTPIdleTimeout
publicAuthUpstreamTimeout, err := loadDurationEnvWithDefault(publicAuthUpstreamTimeoutEnvVar, cfg.PublicHTTP.AuthUpstreamTimeout)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.AuthUpstreamTimeout = publicAuthUpstreamTimeout
if v, ok := os.LookupEnv(publicHTTPCORSAllowedOriginsEnvVar); ok {
origins := make([]string, 0)
for part := range strings.SplitSeq(v, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
origins = append(origins, trimmed)
}
}
cfg.PublicHTTP.CORSAllowedOrigins = origins
}
if v, ok := os.LookupEnv(authenticatedGRPCCORSAllowedOriginsEnvVar); ok {
origins := make([]string, 0)
for part := range strings.SplitSeq(v, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
origins = append(origins, trimmed)
}
}
cfg.AuthenticatedGRPC.CORSAllowedOrigins = origins
}
if v, ok := os.LookupEnv(backendHTTPURLEnvVar); ok {
cfg.Backend.HTTPBaseURL = v
}
if v, ok := os.LookupEnv(backendGRPCPushURLEnvVar); ok {
cfg.Backend.GRPCPushURL = v
}
if v, ok := os.LookupEnv(backendGatewayClientIDEnvVar); ok {
cfg.Backend.GatewayClientID = v
}
backendHTTPTimeout, err := loadDurationEnvWithDefault(backendHTTPTimeoutEnvVar, cfg.Backend.HTTPTimeout)
if err != nil {
return Config{}, err
}
cfg.Backend.HTTPTimeout = backendHTTPTimeout
backendPushReconnectBaseBackoff, err := loadDurationEnvWithDefault(backendPushReconnectBaseBackoffEnvVar, cfg.Backend.PushReconnectBaseBackoff)
if err != nil {
return Config{}, err
}
cfg.Backend.PushReconnectBaseBackoff = backendPushReconnectBaseBackoff
backendPushReconnectMaxBackoff, err := loadDurationEnvWithDefault(backendPushReconnectMaxBackoffEnvVar, cfg.Backend.PushReconnectMaxBackoff)
if err != nil {
return Config{}, err
}
cfg.Backend.PushReconnectMaxBackoff = backendPushReconnectMaxBackoff
rawAdminHTTPAddr, ok := os.LookupEnv(adminHTTPAddrEnvVar)
if ok {
cfg.AdminHTTP.Addr = rawAdminHTTPAddr
}
adminHTTPReadHeaderTimeout, err := loadDurationEnvWithDefault(adminHTTPReadHeaderTimeoutEnvVar, cfg.AdminHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.ReadHeaderTimeout = adminHTTPReadHeaderTimeout
adminHTTPReadTimeout, err := loadDurationEnvWithDefault(adminHTTPReadTimeoutEnvVar, cfg.AdminHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.ReadTimeout = adminHTTPReadTimeout
adminHTTPIdleTimeout, err := loadDurationEnvWithDefault(adminHTTPIdleTimeoutEnvVar, cfg.AdminHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.IdleTimeout = adminHTTPIdleTimeout
rawAuthenticatedGRPCAddr, ok := os.LookupEnv(authenticatedGRPCAddrEnvVar)
if ok {
cfg.AuthenticatedGRPC.Addr = rawAuthenticatedGRPCAddr
}
authenticatedGRPCConnectionTimeout, err := loadDurationEnvWithDefault(authenticatedGRPCConnectionTimeoutEnvVar, cfg.AuthenticatedGRPC.ConnectionTimeout)
if err != nil {
return Config{}, err
}
cfg.AuthenticatedGRPC.ConnectionTimeout = authenticatedGRPCConnectionTimeout
authenticatedGRPCDownstreamTimeout, err := loadDurationEnvWithDefault(authenticatedGRPCDownstreamTimeoutEnvVar, cfg.AuthenticatedGRPC.DownstreamTimeout)
if err != nil {
return Config{}, err
}
cfg.AuthenticatedGRPC.DownstreamTimeout = authenticatedGRPCDownstreamTimeout
pushHeartbeatInterval, err := loadDurationEnvWithDefault(pushHeartbeatIntervalEnvVar, cfg.AuthenticatedGRPC.PushHeartbeatInterval)
if err != nil {
return Config{}, err
}
cfg.AuthenticatedGRPC.PushHeartbeatInterval = pushHeartbeatInterval
authenticatedGRPCFreshnessWindow, err := loadDurationEnvWithDefault(authenticatedGRPCFreshnessWindowEnvVar, cfg.AuthenticatedGRPC.FreshnessWindow)
if err != nil {
return Config{}, err
}
cfg.AuthenticatedGRPC.FreshnessWindow = authenticatedGRPCFreshnessWindow
authenticatedGRPCIPRateLimit, err := loadRateLimitConfigFromEnv(
cfg.AuthenticatedGRPC.AntiAbuse.IP,
authenticatedGRPCIPRateLimitRequestsEnvVar,
authenticatedGRPCIPRateLimitWindowEnvVar,
authenticatedGRPCIPRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.AuthenticatedGRPC.AntiAbuse.IP = authenticatedGRPCIPRateLimit
authenticatedGRPCSessionRateLimit, err := loadRateLimitConfigFromEnv(
cfg.AuthenticatedGRPC.AntiAbuse.Session,
authenticatedGRPCSessionRateLimitRequestsEnvVar,
authenticatedGRPCSessionRateLimitWindowEnvVar,
authenticatedGRPCSessionRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.AuthenticatedGRPC.AntiAbuse.Session = authenticatedGRPCSessionRateLimit
authenticatedGRPCUserRateLimit, err := loadRateLimitConfigFromEnv(
cfg.AuthenticatedGRPC.AntiAbuse.User,
authenticatedGRPCUserRateLimitRequestsEnvVar,
authenticatedGRPCUserRateLimitWindowEnvVar,
authenticatedGRPCUserRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.AuthenticatedGRPC.AntiAbuse.User = authenticatedGRPCUserRateLimit
messageClassRateLimit, err := loadRateLimitConfigFromEnv(
cfg.AuthenticatedGRPC.AntiAbuse.MessageClass,
authenticatedGRPCMessageClassRateLimitRequestsEnvVar,
authenticatedGRPCMessageClassRateLimitWindowEnvVar,
authenticatedGRPCMessageClassRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.AuthenticatedGRPC.AntiAbuse.MessageClass = messageClassRateLimit
redisConn, err := redisconn.LoadFromEnv(gatewayRedisEnvPrefix)
if err != nil {
return Config{}, err
}
cfg.Redis = redisConn
sessionCacheMaxEntries, err := loadIntEnvWithDefault(sessionCacheMaxEntriesEnvVar, cfg.SessionCache.MaxEntries)
if err != nil {
return Config{}, err
}
cfg.SessionCache.MaxEntries = sessionCacheMaxEntries
sessionCacheTTL, err := loadDurationEnvWithDefault(sessionCacheTTLEnvVar, cfg.SessionCache.TTL)
if err != nil {
return Config{}, err
}
cfg.SessionCache.TTL = sessionCacheTTL
rawReplayRedisKeyPrefix, ok := os.LookupEnv(replayRedisKeyPrefixEnvVar)
if ok {
cfg.ReplayRedis.KeyPrefix = rawReplayRedisKeyPrefix
}
replayRedisReserveTimeout, err := loadDurationEnvWithDefault(replayRedisReserveTimeoutEnvVar, cfg.ReplayRedis.ReserveTimeout)
if err != nil {
return Config{}, err
}
cfg.ReplayRedis.ReserveTimeout = replayRedisReserveTimeout
rawSignerKeyPath, ok := os.LookupEnv(responseSignerPrivateKeyPEMPathEnvVar)
if ok {
cfg.ResponseSigner.PrivateKeyPEMPath = rawSignerKeyPath
}
publicAuthPolicy, err := loadPublicRoutePolicyConfigFromEnv(
cfg.PublicHTTP.AntiAbuse.PublicAuth,
publicAuthMaxBodyBytesEnvVar,
publicAuthRateLimitRequestsEnvVar,
publicAuthRateLimitWindowEnvVar,
publicAuthRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.AntiAbuse.PublicAuth = publicAuthPolicy
browserBootstrapPolicy, err := loadPublicRoutePolicyConfigFromEnv(
cfg.PublicHTTP.AntiAbuse.BrowserBootstrap,
browserBootstrapMaxBodyBytesEnvVar,
browserBootstrapRateLimitRequestsEnvVar,
browserBootstrapRateLimitWindowEnvVar,
browserBootstrapRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.AntiAbuse.BrowserBootstrap = browserBootstrapPolicy
browserAssetPolicy, err := loadPublicRoutePolicyConfigFromEnv(
cfg.PublicHTTP.AntiAbuse.BrowserAsset,
browserAssetMaxBodyBytesEnvVar,
browserAssetRateLimitRequestsEnvVar,
browserAssetRateLimitWindowEnvVar,
browserAssetRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.AntiAbuse.BrowserAsset = browserAssetPolicy
publicMiscPolicy, err := loadPublicRoutePolicyConfigFromEnv(
cfg.PublicHTTP.AntiAbuse.PublicMisc,
publicMiscMaxBodyBytesEnvVar,
publicMiscRateLimitRequestsEnvVar,
publicMiscRateLimitWindowEnvVar,
publicMiscRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.AntiAbuse.PublicMisc = publicMiscPolicy
sendIdentityPolicy, err := loadPublicAuthIdentityPolicyConfigFromEnv(
cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity,
sendEmailCodeIdentityRateLimitRequestsEnvVar,
sendEmailCodeIdentityRateLimitWindowEnvVar,
sendEmailCodeIdentityRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity = sendIdentityPolicy
confirmIdentityPolicy, err := loadPublicAuthIdentityPolicyConfigFromEnv(
cfg.PublicHTTP.AntiAbuse.ConfirmEmailCodeIdentity,
confirmEmailCodeIdentityRateLimitRequestsEnvVar,
confirmEmailCodeIdentityRateLimitWindowEnvVar,
confirmEmailCodeIdentityRateLimitBurstEnvVar,
)
if err != nil {
return Config{}, err
}
cfg.PublicHTTP.AntiAbuse.ConfirmEmailCodeIdentity = confirmIdentityPolicy
if cfg.ShutdownTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", shutdownTimeoutEnvVar)
}
if err := validateLogLevel(cfg.Logging.Level); err != nil {
return Config{}, fmt.Errorf("load gateway config: %w", err)
}
if strings.TrimSpace(cfg.PublicHTTP.Addr) == "" {
return Config{}, fmt.Errorf("load gateway config: %s must not be empty", publicHTTPAddrEnvVar)
}
if cfg.PublicHTTP.ReadHeaderTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", publicHTTPReadHeaderTimeoutEnvVar)
}
if cfg.PublicHTTP.ReadTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", publicHTTPReadTimeoutEnvVar)
}
if cfg.PublicHTTP.IdleTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", publicHTTPIdleTimeoutEnvVar)
}
if cfg.PublicHTTP.AuthUpstreamTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", publicAuthUpstreamTimeoutEnvVar)
}
cfg.Backend.HTTPBaseURL = strings.TrimSpace(cfg.Backend.HTTPBaseURL)
if cfg.Backend.HTTPBaseURL == "" {
return Config{}, fmt.Errorf("load gateway config: %s must not be empty", backendHTTPURLEnvVar)
}
parsedBackendHTTP, err := url.Parse(strings.TrimRight(cfg.Backend.HTTPBaseURL, "/"))
if err != nil {
return Config{}, fmt.Errorf("load gateway config: parse %s: %w", backendHTTPURLEnvVar, err)
}
if parsedBackendHTTP.Scheme == "" || parsedBackendHTTP.Host == "" {
return Config{}, fmt.Errorf("load gateway config: %s must be an absolute URL", backendHTTPURLEnvVar)
}
cfg.Backend.HTTPBaseURL = parsedBackendHTTP.String()
cfg.Backend.GRPCPushURL = strings.TrimSpace(cfg.Backend.GRPCPushURL)
if cfg.Backend.GRPCPushURL == "" {
return Config{}, fmt.Errorf("load gateway config: %s must not be empty", backendGRPCPushURLEnvVar)
}
cfg.Backend.GatewayClientID = strings.TrimSpace(cfg.Backend.GatewayClientID)
if cfg.Backend.GatewayClientID == "" {
return Config{}, fmt.Errorf("load gateway config: %s must not be empty", backendGatewayClientIDEnvVar)
}
if cfg.Backend.HTTPTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", backendHTTPTimeoutEnvVar)
}
if cfg.Backend.PushReconnectBaseBackoff <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", backendPushReconnectBaseBackoffEnvVar)
}
if cfg.Backend.PushReconnectMaxBackoff < cfg.Backend.PushReconnectBaseBackoff {
return Config{}, fmt.Errorf("load gateway config: %s must be >= %s", backendPushReconnectMaxBackoffEnvVar, backendPushReconnectBaseBackoffEnvVar)
}
if addr := strings.TrimSpace(cfg.AdminHTTP.Addr); addr != "" {
cfg.AdminHTTP.Addr = addr
}
if cfg.AdminHTTP.ReadHeaderTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", adminHTTPReadHeaderTimeoutEnvVar)
}
if cfg.AdminHTTP.ReadTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", adminHTTPReadTimeoutEnvVar)
}
if cfg.AdminHTTP.IdleTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", adminHTTPIdleTimeoutEnvVar)
}
if strings.TrimSpace(cfg.AuthenticatedGRPC.Addr) == "" {
return Config{}, fmt.Errorf("load gateway config: %s must not be empty", authenticatedGRPCAddrEnvVar)
}
if cfg.AuthenticatedGRPC.ConnectionTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", authenticatedGRPCConnectionTimeoutEnvVar)
}
if cfg.AuthenticatedGRPC.DownstreamTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", authenticatedGRPCDownstreamTimeoutEnvVar)
}
if cfg.AuthenticatedGRPC.FreshnessWindow <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", authenticatedGRPCFreshnessWindowEnvVar)
}
if cfg.AuthenticatedGRPC.PushHeartbeatInterval < 0 {
return Config{}, fmt.Errorf("load gateway config: %s must not be negative", pushHeartbeatIntervalEnvVar)
}
if err := validateRateLimitConfig(
cfg.AuthenticatedGRPC.AntiAbuse.IP,
authenticatedGRPCIPRateLimitRequestsEnvVar,
authenticatedGRPCIPRateLimitWindowEnvVar,
authenticatedGRPCIPRateLimitBurstEnvVar,
); err != nil {
return Config{}, err
}
if err := validateRateLimitConfig(
cfg.AuthenticatedGRPC.AntiAbuse.Session,
authenticatedGRPCSessionRateLimitRequestsEnvVar,
authenticatedGRPCSessionRateLimitWindowEnvVar,
authenticatedGRPCSessionRateLimitBurstEnvVar,
); err != nil {
return Config{}, err
}
if err := validateRateLimitConfig(
cfg.AuthenticatedGRPC.AntiAbuse.User,
authenticatedGRPCUserRateLimitRequestsEnvVar,
authenticatedGRPCUserRateLimitWindowEnvVar,
authenticatedGRPCUserRateLimitBurstEnvVar,
); err != nil {
return Config{}, err
}
if err := validateRateLimitConfig(
cfg.AuthenticatedGRPC.AntiAbuse.MessageClass,
authenticatedGRPCMessageClassRateLimitRequestsEnvVar,
authenticatedGRPCMessageClassRateLimitWindowEnvVar,
authenticatedGRPCMessageClassRateLimitBurstEnvVar,
); err != nil {
return Config{}, err
}
if err := cfg.Redis.Validate(); err != nil {
return Config{}, fmt.Errorf("load gateway config: redis: %w", err)
}
if strings.TrimSpace(cfg.ReplayRedis.KeyPrefix) == "" {
return Config{}, fmt.Errorf("load gateway config: %s must not be empty", replayRedisKeyPrefixEnvVar)
}
if cfg.ReplayRedis.ReserveTimeout <= 0 {
return Config{}, fmt.Errorf("load gateway config: %s must be positive", replayRedisReserveTimeoutEnvVar)
}
if strings.TrimSpace(cfg.ResponseSigner.PrivateKeyPEMPath) == "" {
return Config{}, fmt.Errorf("load gateway config: %s must not be empty", responseSignerPrivateKeyPEMPathEnvVar)
}
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.PublicAuth, publicAuthMaxBodyBytesEnvVar, publicAuthRateLimitRequestsEnvVar, publicAuthRateLimitWindowEnvVar, publicAuthRateLimitBurstEnvVar); err != nil {
return Config{}, err
}
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.BrowserBootstrap, browserBootstrapMaxBodyBytesEnvVar, browserBootstrapRateLimitRequestsEnvVar, browserBootstrapRateLimitWindowEnvVar, browserBootstrapRateLimitBurstEnvVar); err != nil {
return Config{}, err
}
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.BrowserAsset, browserAssetMaxBodyBytesEnvVar, browserAssetRateLimitRequestsEnvVar, browserAssetRateLimitWindowEnvVar, browserAssetRateLimitBurstEnvVar); err != nil {
return Config{}, err
}
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.PublicMisc, publicMiscMaxBodyBytesEnvVar, publicMiscRateLimitRequestsEnvVar, publicMiscRateLimitWindowEnvVar, publicMiscRateLimitBurstEnvVar); err != nil {
return Config{}, err
}
if err := validatePublicAuthIdentityPolicyConfig(cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity, sendEmailCodeIdentityRateLimitRequestsEnvVar, sendEmailCodeIdentityRateLimitWindowEnvVar, sendEmailCodeIdentityRateLimitBurstEnvVar); err != nil {
return Config{}, err
}
if err := validatePublicAuthIdentityPolicyConfig(cfg.PublicHTTP.AntiAbuse.ConfirmEmailCodeIdentity, confirmEmailCodeIdentityRateLimitRequestsEnvVar, confirmEmailCodeIdentityRateLimitWindowEnvVar, confirmEmailCodeIdentityRateLimitBurstEnvVar); err != nil {
return Config{}, err
}
return cfg, nil
}
func loadPublicRoutePolicyConfigFromEnv(defaults PublicRoutePolicyConfig, maxBodyEnvVar string, requestsEnvVar string, windowEnvVar string, burstEnvVar string) (PublicRoutePolicyConfig, error) {
policy := defaults
maxBodyBytes, err := loadInt64EnvWithDefault(maxBodyEnvVar, defaults.MaxBodyBytes)
if err != nil {
return PublicRoutePolicyConfig{}, err
}
policy.MaxBodyBytes = maxBodyBytes
rateLimit, err := loadRateLimitConfigFromEnv(defaults.RateLimit, requestsEnvVar, windowEnvVar, burstEnvVar)
if err != nil {
return PublicRoutePolicyConfig{}, err
}
policy.RateLimit = rateLimit
return policy, nil
}
func loadPublicAuthIdentityPolicyConfigFromEnv(defaults PublicAuthIdentityPolicyConfig, requestsEnvVar string, windowEnvVar string, burstEnvVar string) (PublicAuthIdentityPolicyConfig, error) {
rateLimit, err := loadRateLimitConfigFromEnv(defaults.RateLimit, requestsEnvVar, windowEnvVar, burstEnvVar)
if err != nil {
return PublicAuthIdentityPolicyConfig{}, err
}
return PublicAuthIdentityPolicyConfig{RateLimit: rateLimit}, nil
}
func loadRateLimitConfigFromEnv(defaults RateLimitConfig, requestsEnvVar string, windowEnvVar string, burstEnvVar string) (RateLimitConfig, error) {
cfg := defaults
requests, err := loadIntEnvWithDefault(requestsEnvVar, defaults.Requests)
if err != nil {
return RateLimitConfig{}, err
}
cfg.Requests = requests
window, err := loadDurationEnvWithDefault(windowEnvVar, defaults.Window)
if err != nil {
return RateLimitConfig{}, err
}
cfg.Window = window
burst, err := loadIntEnvWithDefault(burstEnvVar, defaults.Burst)
if err != nil {
return RateLimitConfig{}, err
}
cfg.Burst = burst
return cfg, nil
}
func validateLogLevel(level string) error {
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug", "info", "warn", "error", "dpanic", "panic", "fatal":
return nil
default:
return fmt.Errorf("%s must be one of debug, info, warn, error, dpanic, panic, fatal", logLevelEnvVar)
}
}
func loadIntEnvWithDefault(envVar string, fallback int) (int, error) {
rawValue, ok := os.LookupEnv(envVar)
if !ok {
return fallback, nil
}
value, err := strconv.Atoi(rawValue)
if err != nil {
return 0, fmt.Errorf("load gateway config: parse %s: %w", envVar, err)
}
return value, nil
}
func loadInt64EnvWithDefault(envVar string, fallback int64) (int64, error) {
rawValue, ok := os.LookupEnv(envVar)
if !ok {
return fallback, nil
}
value, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
return 0, fmt.Errorf("load gateway config: parse %s: %w", envVar, err)
}
return value, nil
}
func loadDurationEnvWithDefault(envVar string, fallback time.Duration) (time.Duration, error) {
rawValue, ok := os.LookupEnv(envVar)
if !ok {
return fallback, nil
}
value, err := time.ParseDuration(rawValue)
if err != nil {
return 0, fmt.Errorf("load gateway config: parse %s: %w", envVar, err)
}
return value, nil
}
func loadBoolEnvWithDefault(envVar string, fallback bool) (bool, error) {
rawValue, ok := os.LookupEnv(envVar)
if !ok {
return fallback, nil
}
value, err := strconv.ParseBool(rawValue)
if err != nil {
return false, fmt.Errorf("load gateway config: parse %s: %w", envVar, err)
}
return value, nil
}
func validatePublicRoutePolicyConfig(cfg PublicRoutePolicyConfig, maxBodyEnvVar string, requestsEnvVar string, windowEnvVar string, burstEnvVar string) error {
if cfg.MaxBodyBytes < 0 {
return fmt.Errorf("load gateway config: %s must not be negative", maxBodyEnvVar)
}
return validateRateLimitConfig(cfg.RateLimit, requestsEnvVar, windowEnvVar, burstEnvVar)
}
func validatePublicAuthIdentityPolicyConfig(cfg PublicAuthIdentityPolicyConfig, requestsEnvVar string, windowEnvVar string, burstEnvVar string) error {
return validateRateLimitConfig(cfg.RateLimit, requestsEnvVar, windowEnvVar, burstEnvVar)
}
func validateRateLimitConfig(cfg RateLimitConfig, requestsEnvVar string, windowEnvVar string, burstEnvVar string) error {
if cfg.Requests <= 0 {
return fmt.Errorf("load gateway config: %s must be positive", requestsEnvVar)
}
if cfg.Window <= 0 {
return fmt.Errorf("load gateway config: %s must be positive", windowEnvVar)
}
if cfg.Burst <= 0 {
return fmt.Errorf("load gateway config: %s must be positive", burstEnvVar)
}
return nil
}