// Package config handles configuration loading and validation. package config import ( "fmt" "os" "path/filepath" "strings" ) // Config captures runtime configuration sourced from environment variables. type Config struct { Environment string HTTPPort string DatabasePath string FrontendDir string CaddyAdminAPI string CaddyConfigDir string CaddyBinary string ImportCaddyfile string ImportDir string JWTSecret string EncryptionKey string ACMEStaging bool Debug bool Security SecurityConfig Emergency EmergencyConfig } // SecurityConfig holds configuration for optional security services. type SecurityConfig struct { CrowdSecMode string CrowdSecAPIURL string CrowdSecAPIKey string CrowdSecConfigDir string WAFMode string RateLimitMode string ACLMode string CerberusEnabled bool // ManagementCIDRs defines IP ranges allowed to use emergency break glass token // Default: RFC1918 private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8) ManagementCIDRs []string } // EmergencyConfig configures the emergency break glass server (Tier 2) // This server provides a separate entry point for emergency recovery when // the main application is blocked by security middleware (Caddy/CrowdSec/ACL). type EmergencyConfig struct { // Enabled controls whether the emergency server starts Enabled bool `env:"CHARON_EMERGENCY_SERVER_ENABLED" envDefault:"false"` // BindAddress is the address to bind the emergency server to // Default: 127.0.0.1:2020 (localhost IPv4 only for security) // Note: Port 2020 avoids conflict with Caddy admin API (port 2019) // // IPv4/IPv6 Binding Options: // - "127.0.0.1:2020" → IPv4 localhost only (most secure, default) // - "[::1]:2020" → IPv6 localhost only // - "0.0.0.0:2020" → All IPv4 interfaces (dual-stack on capable systems) // - "[::]:2020" → All IPv6 interfaces (dual-stack on capable systems) // - ":2020" → All interfaces (IPv4/IPv6 based on system config) // // Production: Should be accessible only via VPN/SSH tunnel BindAddress string `env:"CHARON_EMERGENCY_BIND" envDefault:"127.0.0.1:2020"` // BasicAuthUsername for emergency server authentication // If empty, NO authentication is enforced (not recommended) BasicAuthUsername string `env:"CHARON_EMERGENCY_USERNAME" envDefault:""` // BasicAuthPassword for emergency server authentication // If empty, NO authentication is enforced (not recommended) BasicAuthPassword string `env:"CHARON_EMERGENCY_PASSWORD" envDefault:""` } // Load reads env vars and falls back to defaults so the server can boot with zero configuration. func Load() (Config, error) { cfg := Config{ Environment: getEnvAny("development", "CHARON_ENV", "CPM_ENV"), HTTPPort: getEnvAny("8080", "CHARON_HTTP_PORT", "CPM_HTTP_PORT"), DatabasePath: getEnvAny(filepath.Join("data", "charon.db"), "CHARON_DB_PATH", "CPM_DB_PATH"), FrontendDir: getEnvAny(filepath.Clean(filepath.Join("..", "frontend", "dist")), "CHARON_FRONTEND_DIR", "CPM_FRONTEND_DIR"), CaddyAdminAPI: getEnvAny("http://localhost:2019", "CHARON_CADDY_ADMIN_API", "CPM_CADDY_ADMIN_API"), CaddyConfigDir: getEnvAny(filepath.Join("data", "caddy"), "CHARON_CADDY_CONFIG_DIR", "CPM_CADDY_CONFIG_DIR"), CaddyBinary: getEnvAny("caddy", "CHARON_CADDY_BINARY", "CPM_CADDY_BINARY"), ImportCaddyfile: getEnvAny("/import/Caddyfile", "CHARON_IMPORT_CADDYFILE", "CPM_IMPORT_CADDYFILE"), ImportDir: getEnvAny(filepath.Join("data", "imports"), "CHARON_IMPORT_DIR", "CPM_IMPORT_DIR"), JWTSecret: getEnvAny("change-me-in-production", "CHARON_JWT_SECRET", "CPM_JWT_SECRET"), EncryptionKey: getEnvAny("", "CHARON_ENCRYPTION_KEY"), ACMEStaging: getEnvAny("", "CHARON_ACME_STAGING", "CPM_ACME_STAGING") == "true", Security: loadSecurityConfig(), Emergency: loadEmergencyConfig(), Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true", } if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o700); err != nil { return Config{}, fmt.Errorf("ensure data directory: %w", err) } if err := os.MkdirAll(cfg.CaddyConfigDir, 0o700); err != nil { return Config{}, fmt.Errorf("ensure caddy config directory: %w", err) } if err := os.MkdirAll(cfg.ImportDir, 0o700); err != nil { return Config{}, fmt.Errorf("ensure import directory: %w", err) } return cfg, nil } // loadSecurityConfig loads the security configuration with proper parsing of array fields func loadSecurityConfig() SecurityConfig { cfg := SecurityConfig{ CrowdSecMode: getEnvAny("disabled", "CERBERUS_SECURITY_CROWDSEC_MODE", "CHARON_SECURITY_CROWDSEC_MODE", "CPM_SECURITY_CROWDSEC_MODE"), CrowdSecAPIURL: getEnvAny("", "CERBERUS_SECURITY_CROWDSEC_API_URL", "CHARON_SECURITY_CROWDSEC_API_URL", "CPM_SECURITY_CROWDSEC_API_URL"), CrowdSecAPIKey: getEnvAny("", "CERBERUS_SECURITY_CROWDSEC_API_KEY", "CHARON_SECURITY_CROWDSEC_API_KEY", "CPM_SECURITY_CROWDSEC_API_KEY"), CrowdSecConfigDir: getEnvAny(filepath.Join("data", "crowdsec"), "CHARON_CROWDSEC_CONFIG_DIR", "CPM_CROWDSEC_CONFIG_DIR"), WAFMode: getEnvAny("disabled", "CERBERUS_SECURITY_WAF_MODE", "CHARON_SECURITY_WAF_MODE", "CPM_SECURITY_WAF_MODE"), RateLimitMode: getEnvAny("disabled", "CERBERUS_SECURITY_RATELIMIT_MODE", "CHARON_SECURITY_RATELIMIT_MODE", "CPM_SECURITY_RATELIMIT_MODE"), ACLMode: getEnvAny("disabled", "CERBERUS_SECURITY_ACL_MODE", "CHARON_SECURITY_ACL_MODE", "CPM_SECURITY_ACL_MODE"), CerberusEnabled: getEnvAny("true", "CERBERUS_SECURITY_CERBERUS_ENABLED", "CHARON_SECURITY_CERBERUS_ENABLED", "CPM_SECURITY_CERBERUS_ENABLED") != "false", } // Parse management CIDRs (comma-separated list) managementCIDRsStr := getEnvAny("", "CHARON_MANAGEMENT_CIDRS") if managementCIDRsStr != "" { // Split by comma and trim spaces for _, cidr := range splitAndTrim(managementCIDRsStr, ",") { if cidr != "" { cfg.ManagementCIDRs = append(cfg.ManagementCIDRs, cidr) } } } return cfg } // loadEmergencyConfig loads the emergency server configuration func loadEmergencyConfig() EmergencyConfig { return EmergencyConfig{ Enabled: getEnvAny("false", "CHARON_EMERGENCY_SERVER_ENABLED") == "true", BindAddress: getEnvAny("127.0.0.1:2020", "CHARON_EMERGENCY_BIND"), BasicAuthUsername: getEnvAny("", "CHARON_EMERGENCY_USERNAME"), BasicAuthPassword: getEnvAny("", "CHARON_EMERGENCY_PASSWORD"), } } // splitAndTrim splits a string by separator and trims each part func splitAndTrim(s, sep string) []string { if s == "" { return nil } parts := strings.Split(s, sep) result := []string{} for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { result = append(result, trimmed) } } return result } // NOTE: getEnv was removed in favor of getEnvAny since the latter supports // checking multiple env var keys with a fallback value. // getEnvAny checks a list of environment variable names in order and returns // the first non-empty value. If none are set, it returns the provided fallback. func getEnvAny(fallback string, keys ...string) string { for _, key := range keys { if val := os.Getenv(key); val != "" { return val } } return fallback }