package config import ( "fmt" "net" "os" "strconv" "strings" "github.com/joho/godotenv" ) type AuthMode string const ( AuthModeLocal AuthMode = "local" AuthModePangolin AuthMode = "pangolin" AuthModeBoth AuthMode = "both" ) type Config struct { ListenAddr string MigaduUser string MigaduAPIKey string MigaduBaseURL string SessionSecret string DatabasePath string AuthMode AuthMode TrustedProxies []*net.IPNet RegistrationOpen bool SSOAutoProvision bool CookieSecure bool } func Load() (*Config, error) { // Prefer project .env over stale exported shell vars (common after editing the key). if err := godotenv.Overload(); err != nil { // Missing .env is fine when vars are provided by the environment. if !os.IsNotExist(err) { // godotenv returns a plain error for missing file; ignore only that case. if !strings.Contains(err.Error(), "no such file") { return nil, fmt.Errorf("load .env: %w", err) } } } authMode := AuthMode(strings.ToLower(strings.TrimSpace(envOr("AUTH_MODE", "both")))) switch authMode { case AuthModeLocal, AuthModePangolin, AuthModeBoth: default: return nil, fmt.Errorf("invalid AUTH_MODE %q (use local, pangolin, or both)", authMode) } trusted, err := parseCIDRs(envOr("TRUSTED_PROXIES", "127.0.0.1/32,::1/128")) if err != nil { return nil, fmt.Errorf("TRUSTED_PROXIES: %w", err) } cfg := &Config{ ListenAddr: envOr("LISTEN_ADDR", ":8080"), MigaduUser: cleanEnv(os.Getenv("MIGADU_USER")), MigaduAPIKey: cleanEnv(os.Getenv("MIGADU_API_KEY")), MigaduBaseURL: strings.TrimRight(envOr("MIGADU_BASE_URL", "https://api.migadu.com/v1"), "/"), SessionSecret: cleanEnv(os.Getenv("SESSION_SECRET")), DatabasePath: envOr("DATABASE_PATH", "data/migaduadmin.db"), AuthMode: authMode, TrustedProxies: trusted, RegistrationOpen: envBool("REGISTRATION_OPEN", false), SSOAutoProvision: envBool("SSO_AUTO_PROVISION", false), CookieSecure: envBool("COOKIE_SECURE", false), } if cfg.MigaduUser == "" { return nil, fmt.Errorf("MIGADU_USER is required") } if cfg.MigaduAPIKey == "" { return nil, fmt.Errorf("MIGADU_API_KEY is required") } if strings.ContainsAny(cfg.MigaduAPIKey, " \t\n\r") { return nil, fmt.Errorf("MIGADU_API_KEY contains whitespace; check .env quoting") } if cfg.SessionSecret == "" || len(cfg.SessionSecret) < 32 { return nil, fmt.Errorf("SESSION_SECRET is required and must be at least 32 characters") } return cfg, nil } // MaskedMigaduKey returns a safe fingerprint for logs. func (c *Config) MaskedMigaduKey() string { key := c.MigaduAPIKey if key == "" { return "(empty)" } if len(key) <= 8 { return "****" } return fmt.Sprintf("%s…%s (len=%d)", key[:4], key[len(key)-4:], len(key)) } func cleanEnv(value string) string { value = strings.TrimSpace(value) if len(value) >= 2 { if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { value = strings.TrimSpace(value[1 : len(value)-1]) } } return value } func (c *Config) IsTrustedProxy(ip net.IP) bool { if ip == nil { return false } for _, network := range c.TrustedProxies { if network.Contains(ip) { return true } } return false } func envOr(key, fallback string) string { if value := cleanEnv(os.Getenv(key)); value != "" { return value } return fallback } func envBool(key string, fallback bool) bool { raw := cleanEnv(os.Getenv(key)) if raw == "" { return fallback } value, err := strconv.ParseBool(raw) if err != nil { return fallback } return value } func parseCIDRs(raw string) ([]*net.IPNet, error) { parts := strings.Split(raw, ",") networks := make([]*net.IPNet, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } if !strings.Contains(part, "/") { if strings.Contains(part, ":") { part += "/128" } else { part += "/32" } } _, network, err := net.ParseCIDR(part) if err != nil { return nil, fmt.Errorf("invalid CIDR %q: %w", part, err) } networks = append(networks, network) } return networks, nil }