package config import ( "encoding/base64" "fmt" "os" "strconv" "strings" "time" ) type Config struct { App AppConfig HTTP HTTPConfig Postgres PostgresConfig Redis RedisConfig Auth AuthConfig Installation InstallationConfig DataPlane DataPlaneConfig Secret SecretConfig Session SessionConfig Worker WorkerConfig WebSocket WebSocketConfig } type AppConfig struct { Name string Env string } type HTTPConfig struct { Host string Port int ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration ShutdownTimeout time.Duration } type PostgresConfig struct { DSN string MaxConns int32 MinConns int32 ConnectTimeout time.Duration } type RedisConfig struct { Addr string Password string DB int DialTimeout time.Duration } type AuthConfig struct { AccessTokenTTL time.Duration RefreshTokenTTL time.Duration Issuer string AccessTokenSecret string RefreshHashSecret string } type InstallationConfig struct { AuthorityMode string ProductRootPublicKeyBase64 string ProductRootPublicKeyFile string AllowInsecureBootstrap bool } type DataPlaneConfig struct { TokenTTL time.Duration TokenPrivateKeyPEM string TokenPrivateKeyFile string BackendGatewayURL string DirectWorkerWSSURLTemplate string DirectWorkerJSONRuntime bool DirectWorkerBinaryRender bool DirectWorkerTLSTrustMode string DirectWorkerTLSCARef string } type SecretConfig struct { EncryptionKeyBase64 string EncryptionKeyFile string EncryptionKeyID string } type SessionConfig struct { HeartbeatTTL time.Duration DetachGracePeriod time.Duration AttachTokenTTL time.Duration LiveStateTTL time.Duration RecoveryBatchSize int } type WorkerConfig struct { LeaseTTL time.Duration HeartbeatTTL time.Duration StaleLeaseGracePeriod time.Duration } type WebSocketConfig struct { WriteTimeout time.Duration PingInterval time.Duration PongWait time.Duration } func Load() (Config, error) { cfg := Config{ App: AppConfig{ Name: getEnv("APP_NAME", "rap-api"), Env: getEnv("APP_ENV", "development"), }, HTTP: HTTPConfig{ Host: getEnv("HTTP_HOST", "0.0.0.0"), Port: getInt("HTTP_PORT", 8080), ReadTimeout: getDuration("HTTP_READ_TIMEOUT", 15*time.Second), WriteTimeout: getDuration("HTTP_WRITE_TIMEOUT", 15*time.Second), IdleTimeout: getDuration("HTTP_IDLE_TIMEOUT", 60*time.Second), ShutdownTimeout: getDuration("HTTP_SHUTDOWN_TIMEOUT", 10*time.Second), }, Postgres: PostgresConfig{ DSN: getEnv("POSTGRES_DSN", ""), MaxConns: int32(getInt("POSTGRES_MAX_CONNS", 20)), MinConns: int32(getInt("POSTGRES_MIN_CONNS", 2)), ConnectTimeout: getDuration("POSTGRES_CONNECT_TIMEOUT", 5*time.Second), }, Redis: RedisConfig{ Addr: getEnv("REDIS_ADDR", "localhost:6379"), Password: getEnv("REDIS_PASSWORD", ""), DB: getInt("REDIS_DB", 0), DialTimeout: getDuration("REDIS_DIAL_TIMEOUT", 5*time.Second), }, Auth: AuthConfig{ AccessTokenTTL: getDuration("AUTH_ACCESS_TOKEN_TTL", 15*time.Minute), RefreshTokenTTL: getDuration("AUTH_REFRESH_TOKEN_TTL", 30*24*time.Hour), Issuer: getEnv("AUTH_ISSUER", "rap-api"), AccessTokenSecret: getEnv("AUTH_ACCESS_TOKEN_SECRET", ""), RefreshHashSecret: getEnv("AUTH_REFRESH_HASH_SECRET", ""), }, Installation: InstallationConfig{ AuthorityMode: getEnv("INSTALLATION_AUTHORITY_MODE", ""), ProductRootPublicKeyBase64: getEnv("INSTALLATION_PRODUCT_ROOT_PUBLIC_KEY_B64", ""), ProductRootPublicKeyFile: getEnv("INSTALLATION_PRODUCT_ROOT_PUBLIC_KEY_FILE", ""), AllowInsecureBootstrap: getBool("INSTALLATION_INSECURE_BOOTSTRAP_ENABLED", false), }, DataPlane: DataPlaneConfig{ TokenTTL: getDuration("DATA_PLANE_TOKEN_TTL", 1*time.Minute), TokenPrivateKeyPEM: getEnv("DATA_PLANE_TOKEN_PRIVATE_KEY_PEM", ""), TokenPrivateKeyFile: getEnv("DATA_PLANE_TOKEN_PRIVATE_KEY_FILE", ""), BackendGatewayURL: getEnv("DATA_PLANE_BACKEND_GATEWAY_URL", "/api/v1/gateway/ws"), DirectWorkerWSSURLTemplate: getEnv("DATA_PLANE_DIRECT_WORKER_WSS_URL_TEMPLATE", ""), DirectWorkerJSONRuntime: getBool("DATA_PLANE_DIRECT_WORKER_JSON_RUNTIME", false), DirectWorkerBinaryRender: getBool("DATA_PLANE_DIRECT_WORKER_BINARY_RENDER", false), DirectWorkerTLSTrustMode: getEnv("DATA_PLANE_DIRECT_WORKER_TLS_TRUST_MODE", "smoke_insecure"), DirectWorkerTLSCARef: getEnv("DATA_PLANE_DIRECT_WORKER_TLS_CA_REF", ""), }, Secret: SecretConfig{ EncryptionKeyBase64: getEnv("SECRET_ENCRYPTION_KEY_B64", ""), EncryptionKeyFile: getEnv("SECRET_ENCRYPTION_KEY_FILE", ""), EncryptionKeyID: getEnv("SECRET_ENCRYPTION_KEY_ID", "local-v1"), }, Session: SessionConfig{ HeartbeatTTL: getDuration("SESSION_HEARTBEAT_TTL", 90*time.Second), DetachGracePeriod: getDuration("SESSION_DETACH_GRACE_PERIOD", 30*time.Minute), AttachTokenTTL: getDuration("SESSION_ATTACH_TOKEN_TTL", 2*time.Minute), LiveStateTTL: getDuration("SESSION_LIVE_STATE_TTL", 2*time.Minute), RecoveryBatchSize: getInt("SESSION_RECOVERY_BATCH_SIZE", 100), }, Worker: WorkerConfig{ LeaseTTL: getDuration("WORKER_LEASE_TTL", 45*time.Second), HeartbeatTTL: getDuration("WORKER_HEARTBEAT_TTL", 15*time.Second), StaleLeaseGracePeriod: getDuration("WORKER_STALE_LEASE_GRACE_PERIOD", 30*time.Second), }, WebSocket: WebSocketConfig{ WriteTimeout: getDuration("WEBSOCKET_WRITE_TIMEOUT", 10*time.Second), PingInterval: getDuration("WEBSOCKET_PING_INTERVAL", 20*time.Second), PongWait: getDuration("WEBSOCKET_PONG_WAIT", 40*time.Second), }, } if cfg.Postgres.DSN == "" { return Config{}, fmt.Errorf("POSTGRES_DSN is required") } if cfg.Auth.AccessTokenSecret == "" { return Config{}, fmt.Errorf("AUTH_ACCESS_TOKEN_SECRET is required") } if cfg.Auth.RefreshHashSecret == "" { return Config{}, fmt.Errorf("AUTH_REFRESH_HASH_SECRET is required") } if cfg.Installation.ProductRootPublicKeyBase64 == "" && cfg.Installation.ProductRootPublicKeyFile != "" { publicKey, err := os.ReadFile(cfg.Installation.ProductRootPublicKeyFile) if err != nil { return Config{}, fmt.Errorf("read INSTALLATION_PRODUCT_ROOT_PUBLIC_KEY_FILE: %w", err) } cfg.Installation.ProductRootPublicKeyBase64 = strings.TrimSpace(string(publicKey)) } cfg.Installation.AuthorityMode = normalizeInstallationAuthorityMode(cfg.Installation.AuthorityMode, cfg.Installation.ProductRootPublicKeyBase64) if isProductionEnv(cfg.App.Env) && cfg.Installation.AuthorityMode != "strict" { return Config{}, fmt.Errorf("INSTALLATION_AUTHORITY_MODE=strict with INSTALLATION_PRODUCT_ROOT_PUBLIC_KEY_B64 or file is required in production") } if cfg.DataPlane.TokenPrivateKeyPEM == "" && cfg.DataPlane.TokenPrivateKeyFile != "" { privateKey, err := os.ReadFile(cfg.DataPlane.TokenPrivateKeyFile) if err != nil { return Config{}, fmt.Errorf("read DATA_PLANE_TOKEN_PRIVATE_KEY_FILE: %w", err) } cfg.DataPlane.TokenPrivateKeyPEM = string(privateKey) } if cfg.Secret.EncryptionKeyBase64 == "" && cfg.Secret.EncryptionKeyFile != "" { secretKey, err := os.ReadFile(cfg.Secret.EncryptionKeyFile) if err != nil { return Config{}, fmt.Errorf("read SECRET_ENCRYPTION_KEY_FILE: %w", err) } cfg.Secret.EncryptionKeyBase64 = strings.TrimSpace(string(secretKey)) } if cfg.Secret.EncryptionKeyBase64 != "" { decoded, err := base64.StdEncoding.DecodeString(cfg.Secret.EncryptionKeyBase64) if err != nil { if decodedRaw, rawErr := base64.RawStdEncoding.DecodeString(cfg.Secret.EncryptionKeyBase64); rawErr == nil { decoded = decodedRaw } else { return Config{}, fmt.Errorf("SECRET_ENCRYPTION_KEY_B64 must be base64 encoded: %w", err) } } if len(decoded) != 32 { return Config{}, fmt.Errorf("SECRET_ENCRYPTION_KEY_B64 must decode to 32 bytes for AES-256-GCM") } } if isProductionEnv(cfg.App.Env) && cfg.Secret.EncryptionKeyBase64 == "" { return Config{}, fmt.Errorf("SECRET_ENCRYPTION_KEY_B64 or SECRET_ENCRYPTION_KEY_FILE is required in production") } return cfg, nil } func normalizeInstallationAuthorityMode(mode string, rootPublicKey string) string { mode = strings.ToLower(strings.TrimSpace(mode)) switch mode { case "strict", "legacy": return mode case "": if strings.TrimSpace(rootPublicKey) != "" { return "strict" } return "legacy" default: return mode } } func isProductionEnv(appEnv string) bool { switch strings.ToLower(strings.TrimSpace(appEnv)) { case "production", "prod": return true default: return false } } func getEnv(key, fallback string) string { if value := os.Getenv(key); value != "" { return value } return fallback } func getInt(key string, fallback int) int { value := os.Getenv(key) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func getBool(key string, fallback bool) bool { value := os.Getenv(key) if value == "" { return fallback } switch value { case "1", "true", "TRUE", "yes", "on": return true case "0", "false", "FALSE", "no", "off": return false default: return fallback } } func getDuration(key string, fallback time.Duration) time.Duration { value := os.Getenv(key) if value == "" { return fallback } parsed, err := time.ParseDuration(value) if err != nil { return fallback } return parsed }