308 lines
9.5 KiB
Go
308 lines
9.5 KiB
Go
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
|
|
}
|