package config import ( "errors" "fmt" "net" "net/url" "os" "path/filepath" "strconv" "strings" "time" ) const ( DefaultIPv4Primary = "https://api.ipify.org" DefaultIPv4Fallback = "https://ifconfig.co/ip" DefaultIPv6Primary = "https://api6.ipify.org" DefaultIPv6Fallback = "https://ifconfig.co/ip" ) type Config struct { Address string DataDir string DatabasePath string MasterKeyFile string CookieSecure bool SessionTTL time.Duration HCloudBaseURL string Mode string ShutdownTimeout time.Duration UpdateAPIURL string UpdateAgentSock string UpdateInterval time.Duration } func Load() (Config, error) { dataDir := value("APP_DATA_DIR", "/data") address := value("APP_ADDRESS", ":8080") if _, _, err := net.SplitHostPort(address); err != nil { return Config{}, fmt.Errorf("APP_ADDRESS must be host:port: %w", err) } mode := strings.ToLower(value("APP_MODE", "lan")) if mode != "lan" && mode != "https" { return Config{}, fmt.Errorf("APP_MODE must be either lan or https") } ttl, err := duration("APP_SESSION_TTL", 8*time.Hour) if err != nil || ttl < time.Hour || ttl > 7*24*time.Hour { return Config{}, fmt.Errorf("APP_SESSION_TTL must be between 1h and 168h") } shutdownTimeout, err := duration("APP_SHUTDOWN_TIMEOUT", 15*time.Second) if err != nil { return Config{}, fmt.Errorf("APP_SHUTDOWN_TIMEOUT: %w", err) } keyFile := value("APP_MASTER_KEY_FILE", "/secrets/master_key") updateInterval, err := duration("APP_SOFTWARE_UPDATE_CHECK_INTERVAL", 6*time.Hour) if err != nil || updateInterval < 15*time.Minute || updateInterval > 7*24*time.Hour { return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_CHECK_INTERVAL must be between 15m and 168h") } updateAPIURL := value("APP_SOFTWARE_UPDATE_API_URL", "https://git.j-mahler.de/api/v1/repos/admin/hetzner-api-dyndns-records-webinterface/releases/latest") if err := validateUpdateURL(updateAPIURL); err != nil { return Config{}, fmt.Errorf("APP_SOFTWARE_UPDATE_API_URL: %w", err) } cookieSecure := mode == "https" if raw := os.Getenv("APP_COOKIE_SECURE"); raw != "" { parsed, parseErr := strconv.ParseBool(raw) if parseErr != nil { return Config{}, fmt.Errorf("APP_COOKIE_SECURE: %w", parseErr) } cookieSecure = parsed } return Config{ Address: address, DataDir: dataDir, DatabasePath: value("APP_DATABASE_PATH", filepath.Join(dataDir, "dyndns.db")), MasterKeyFile: keyFile, CookieSecure: cookieSecure, SessionTTL: ttl, HCloudBaseURL: strings.TrimRight(value("APP_HCLOUD_BASE_URL", "https://api.hetzner.cloud/v1"), "/"), Mode: mode, ShutdownTimeout: shutdownTimeout, UpdateAPIURL: updateAPIURL, UpdateAgentSock: value("APP_SOFTWARE_UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"), UpdateInterval: updateInterval, }, nil } func validateUpdateURL(raw string) error { parsed, err := url.Parse(raw) if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { return errors.New("must be an absolute HTTPS URL without embedded credentials") } if parsed.Scheme == "https" { return nil } host := parsed.Hostname() address := net.ParseIP(host) if host == "localhost" || (address != nil && address.IsLoopback()) { return nil } return errors.New("HTTP is allowed only for loopback test endpoints") } func value(name, fallback string) string { if v := strings.TrimSpace(os.Getenv(name)); v != "" { return v } return fallback } func duration(name string, fallback time.Duration) (time.Duration, error) { if raw := strings.TrimSpace(os.Getenv(name)); raw != "" { return time.ParseDuration(raw) } return fallback, nil }