package config import ( "encoding/base64" "errors" "fmt" "net/url" "os" "strconv" "strings" "time" ) type Config struct { Environment string HTTPAddr string DatabaseURL string EncryptionKey []byte PhoneHMACKey []byte SessionCookieName string SessionSecure bool SessionTTL time.Duration MigrateOnStart bool ShutdownTimeout time.Duration DBMaxOpenConns int DBMaxIdleConns int DBConnMaxIdleTime time.Duration DBConnMaxLifetime time.Duration MaintenanceInterval time.Duration MaintenanceBatchSize int PublicTicketProjectLimit int PublicTicketPhoneLimit int PublicTicketRateWindow time.Duration } func Load() (Config, error) { return load(os.LookupEnv) } func load(lookup func(string) (string, bool)) (Config, error) { get := func(name, fallback string) string { if value, ok := lookup(name); ok { return strings.TrimSpace(value) } return fallback } encryptionKey, err := decodeKey("DATA_ENCRYPTION_KEY_BASE64", get("DATA_ENCRYPTION_KEY_BASE64", "")) if err != nil { return Config{}, err } phoneHMACKey, err := decodeKey("PHONE_HMAC_KEY_BASE64", get("PHONE_HMAC_KEY_BASE64", "")) if err != nil { return Config{}, err } sessionTTL, err := time.ParseDuration(get("SESSION_TTL", "12h")) if err != nil || sessionTTL < 15*time.Minute || sessionTTL > 7*24*time.Hour { return Config{}, errors.New("SESSION_TTL must be a duration between 15m and 168h") } shutdownTimeout, err := time.ParseDuration(get("SHUTDOWN_TIMEOUT", "10s")) if err != nil || shutdownTimeout < time.Second || shutdownTimeout > time.Minute { return Config{}, errors.New("SHUTDOWN_TIMEOUT must be a duration between 1s and 1m") } sessionSecure, err := strconv.ParseBool(get("SESSION_COOKIE_SECURE", "true")) if err != nil { return Config{}, fmt.Errorf("SESSION_COOKIE_SECURE: %w", err) } migrate, err := strconv.ParseBool(get("MIGRATE_ON_START", "true")) if err != nil { return Config{}, fmt.Errorf("MIGRATE_ON_START: %w", err) } environment := get("APP_ENV", "development") databaseURL := get("DATABASE_URL", "") if databaseURL == "" { return Config{}, errors.New("DATABASE_URL is required") } maxOpen, err := parseIntSetting("DB_MAX_OPEN_CONNS", get("DB_MAX_OPEN_CONNS", "20"), 1, 1000) if err != nil { return Config{}, err } maxIdle, err := parseIntSetting("DB_MAX_IDLE_CONNS", get("DB_MAX_IDLE_CONNS", "5"), 0, 1000) if err != nil { return Config{}, err } if maxIdle > maxOpen { return Config{}, errors.New("DB_MAX_IDLE_CONNS cannot exceed DB_MAX_OPEN_CONNS") } connMaxIdleTime, err := parseDurationSetting("DB_CONN_MAX_IDLE_TIME", get("DB_CONN_MAX_IDLE_TIME", "5m"), time.Minute, 24*time.Hour) if err != nil { return Config{}, err } connMaxLifetime, err := parseDurationSetting("DB_CONN_MAX_LIFETIME", get("DB_CONN_MAX_LIFETIME", "30m"), time.Minute, 7*24*time.Hour) if err != nil { return Config{}, err } maintenanceInterval, err := parseDurationSetting("MAINTENANCE_INTERVAL", get("MAINTENANCE_INTERVAL", "1h"), 0, 24*time.Hour) if err != nil { return Config{}, err } maintenanceBatchSize, err := parseIntSetting("MAINTENANCE_BATCH_SIZE", get("MAINTENANCE_BATCH_SIZE", "500"), 1, 10000) if err != nil { return Config{}, err } publicTicketProjectLimit, err := parseIntSetting("PUBLIC_TICKET_PROJECT_LIMIT", get("PUBLIC_TICKET_PROJECT_LIMIT", "1000"), 1, 1000000) if err != nil { return Config{}, err } publicTicketPhoneLimit, err := parseIntSetting("PUBLIC_TICKET_PHONE_LIMIT", get("PUBLIC_TICKET_PHONE_LIMIT", "5"), 1, 10000) if err != nil { return Config{}, err } publicTicketRateWindow, err := parseDurationSetting("PUBLIC_TICKET_RATE_WINDOW", get("PUBLIC_TICKET_RATE_WINDOW", "1m"), time.Second, time.Hour) if err != nil { return Config{}, err } if environment == "production" && !sessionSecure { return Config{}, errors.New("SESSION_COOKIE_SECURE must be true in production") } if environment == "production" && migrate { return Config{}, errors.New("MIGRATE_ON_START must be false in production; run migrations as a release job") } if environment == "production" && !postgresTLSConfigured(databaseURL) { return Config{}, errors.New("DATABASE_URL must use PostgreSQL TLS in production") } if environment == "production" && maintenanceInterval <= 0 { return Config{}, errors.New("MAINTENANCE_INTERVAL must be positive in production") } cookieName := get("SESSION_COOKIE_NAME", "queue_session") if cookieName == "" || strings.ContainsAny(cookieName, "()<>@,;:\\\"/[]?={} \t") { return Config{}, errors.New("SESSION_COOKIE_NAME is not a valid cookie name") } return Config{ Environment: environment, HTTPAddr: get("HTTP_ADDR", ":8080"), DatabaseURL: databaseURL, EncryptionKey: encryptionKey, PhoneHMACKey: phoneHMACKey, SessionCookieName: cookieName, SessionSecure: sessionSecure, SessionTTL: sessionTTL, MigrateOnStart: migrate, ShutdownTimeout: shutdownTimeout, DBMaxOpenConns: maxOpen, DBMaxIdleConns: maxIdle, DBConnMaxIdleTime: connMaxIdleTime, DBConnMaxLifetime: connMaxLifetime, MaintenanceInterval: maintenanceInterval, MaintenanceBatchSize: maintenanceBatchSize, PublicTicketProjectLimit: publicTicketProjectLimit, PublicTicketPhoneLimit: publicTicketPhoneLimit, PublicTicketRateWindow: publicTicketRateWindow, }, nil } func parseIntSetting(name, value string, min, max int) (int, error) { parsed, err := strconv.Atoi(strings.TrimSpace(value)) if err != nil || parsed < min || parsed > max { return 0, fmt.Errorf("%s must be an integer between %d and %d", name, min, max) } return parsed, nil } func parseDurationSetting(name, value string, min, max time.Duration) (time.Duration, error) { parsed, err := time.ParseDuration(strings.TrimSpace(value)) if err != nil || parsed < min || parsed > max { return 0, fmt.Errorf("%s must be a duration between %s and %s", name, min, max) } return parsed, nil } func postgresTLSConfigured(dsn string) bool { value := strings.TrimSpace(dsn) if parsed, err := url.Parse(value); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") { return secureSSLMode(parsed.Query().Get("sslmode")) } for _, field := range strings.Fields(value) { if strings.HasPrefix(field, "sslmode=") { return secureSSLMode(strings.TrimPrefix(field, "sslmode=")) } } return false } func secureSSLMode(mode string) bool { switch strings.ToLower(strings.TrimSpace(mode)) { case "require", "verify-ca", "verify-full": return true default: return false } } func decodeKey(name, value string) ([]byte, error) { if value == "" { return nil, fmt.Errorf("%s is required", name) } decoded, err := base64.StdEncoding.DecodeString(value) if err != nil { return nil, fmt.Errorf("%s must be standard base64: %w", name, err) } if len(decoded) != 32 { return nil, fmt.Errorf("%s must decode to exactly 32 bytes", name) } return decoded, nil }