package config import ( "fmt" "os" "strings" "gopkg.in/yaml.v3" ) // Config is the top-level configuration structure. // Contains ONLY infrastructure/customer identity. // App-specific config lives in per-app app.yaml files. type Config struct { Customer CustomerConfig `yaml:"customer"` Infrastructure InfrastructureConfig `yaml:"infrastructure"` Paths PathsConfig `yaml:"paths"` Web WebConfig `yaml:"web"` Git GitConfig `yaml:"git"` Stacks StacksConfig `yaml:"stacks"` Backup BackupConfig `yaml:"backup"` Monitoring MonitoringConfig `yaml:"monitoring"` SelfUpdate SelfUpdateConfig `yaml:"self_update"` Notifications NotificationsConfig `yaml:"notifications"` Logging LoggingConfig `yaml:"logging"` Assets AssetsConfig `yaml:"assets"` } type CustomerConfig struct { ID string `yaml:"id"` Name string `yaml:"name"` Domain string `yaml:"domain"` Email string `yaml:"email"` TelegramChatID string `yaml:"telegram_chat_id"` } type InfrastructureConfig struct { CFTunnelToken string `yaml:"cf_tunnel_token"` CFAPIToken string `yaml:"cf_api_token"` } type PathsConfig struct { StacksDir string `yaml:"stacks_dir"` DataDir string `yaml:"data_dir"` BackupDir string `yaml:"backup_dir"` DBDumpDir string `yaml:"db_dump_dir"` HDDPath string `yaml:"hdd_path"` } type WebConfig struct { Listen string `yaml:"listen"` PasswordHash string `yaml:"password_hash"` SessionSecret string `yaml:"session_secret"` } type GitConfig struct { RepoURL string `yaml:"repo_url"` Branch string `yaml:"branch"` SyncInterval string `yaml:"sync_interval"` Username string `yaml:"username"` Token string `yaml:"token"` } type StacksConfig struct { Protected []string `yaml:"protected"` UpdateWindow string `yaml:"update_window"` ComposeCommand string `yaml:"compose_command"` } type BackupConfig struct { Enabled bool `yaml:"enabled"` ResticRepo string `yaml:"restic_repo"` ResticPasswordFile string `yaml:"restic_password_file"` DBDumpSchedule string `yaml:"db_dump_schedule"` ResticSchedule string `yaml:"restic_schedule"` Retention RetentionConfig `yaml:"retention"` PruneSchedule string `yaml:"prune_schedule"` } type RetentionConfig struct { KeepDaily int `yaml:"keep_daily"` KeepWeekly int `yaml:"keep_weekly"` KeepMonthly int `yaml:"keep_monthly"` } type MonitoringConfig struct { Enabled bool `yaml:"enabled"` HealthchecksBase string `yaml:"healthchecks_base"` PingUUIDs PingUUIDsConfig `yaml:"ping_uuids"` HealthCheckSchedule string `yaml:"health_check_schedule"` Thresholds ThresholdsConfig `yaml:"thresholds"` } type PingUUIDsConfig struct { DBDump string `yaml:"db_dump"` Backup string `yaml:"backup"` SystemHealth string `yaml:"system_health"` } type ThresholdsConfig struct { DiskWarnPercent int `yaml:"disk_warn_percent"` DiskCritPercent int `yaml:"disk_crit_percent"` BackupMaxAgeHours int `yaml:"backup_max_age_hours"` CPUWarnPercent int `yaml:"cpu_warn_percent"` MemoryWarnPercent int `yaml:"memory_warn_percent"` TemperatureWarnCelsius int `yaml:"temperature_warn_celsius"` } type SelfUpdateConfig struct { Enabled bool `yaml:"enabled"` CheckInterval string `yaml:"check_interval"` Image string `yaml:"image"` AutoUpdate bool `yaml:"auto_update"` HealthTimeoutSeconds int `yaml:"health_timeout_seconds"` } type NotificationsConfig struct { CustomerEvents []string `yaml:"customer_events"` OperatorEvents []string `yaml:"operator_events"` } type LoggingConfig struct { Level string `yaml:"level"` File string `yaml:"file"` MaxSizeMB int `yaml:"max_size_mb"` MaxFiles int `yaml:"max_files"` } type AssetsConfig struct { SourceURL string `yaml:"source_url"` // Only used during build, not runtime } // Load reads and parses the config file, applies defaults, and validates. func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config file: %w", err) } // Expand environment variables in the YAML expanded := os.ExpandEnv(string(data)) cfg := &Config{} if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil { return nil, fmt.Errorf("parsing config file: %w", err) } applyDefaults(cfg) applyEnvOverrides(cfg) if err := validate(cfg); err != nil { return nil, fmt.Errorf("config validation: %w", err) } return cfg, nil } func applyDefaults(cfg *Config) { d := func(val *string, def string) { if *val == "" { *val = def } } di := func(val *int, def int) { if *val == 0 { *val = def } } d(&cfg.Paths.StacksDir, "/opt/docker/stacks") d(&cfg.Paths.DataDir, "/opt/docker/felhom-controller/data") d(&cfg.Paths.BackupDir, "/srv/backups") d(&cfg.Paths.DBDumpDir, "/srv/backups/db-dumps") d(&cfg.Web.Listen, ":8080") d(&cfg.Git.Branch, "main") d(&cfg.Git.SyncInterval, "15m") d(&cfg.Stacks.UpdateWindow, "03:00-05:00") d(&cfg.Backup.ResticRepo, "/srv/backups/restic-repo") d(&cfg.Backup.DBDumpSchedule, "02:30") d(&cfg.Backup.ResticSchedule, "03:00") d(&cfg.Backup.PruneSchedule, "weekly") di(&cfg.Backup.Retention.KeepDaily, 7) di(&cfg.Backup.Retention.KeepWeekly, 4) di(&cfg.Backup.Retention.KeepMonthly, 6) d(&cfg.Monitoring.HealthchecksBase, "https://status.felhom.eu") d(&cfg.Monitoring.HealthCheckSchedule, "06:00") di(&cfg.Monitoring.Thresholds.DiskWarnPercent, 80) di(&cfg.Monitoring.Thresholds.DiskCritPercent, 90) di(&cfg.Monitoring.Thresholds.BackupMaxAgeHours, 36) di(&cfg.Monitoring.Thresholds.CPUWarnPercent, 90) di(&cfg.Monitoring.Thresholds.MemoryWarnPercent, 85) di(&cfg.Monitoring.Thresholds.TemperatureWarnCelsius, 75) d(&cfg.SelfUpdate.CheckInterval, "6h") di(&cfg.SelfUpdate.HealthTimeoutSeconds, 60) d(&cfg.Logging.Level, "info") di(&cfg.Logging.MaxSizeMB, 10) di(&cfg.Logging.MaxFiles, 3) d(&cfg.Assets.SourceURL, "https://felhom.eu") } func applyEnvOverrides(cfg *Config) { envStr := func(key string, target *string) { if v := os.Getenv(key); v != "" { *target = v } } envStr("FELHOM_CUSTOMER_ID", &cfg.Customer.ID) envStr("FELHOM_CUSTOMER_DOMAIN", &cfg.Customer.Domain) envStr("FELHOM_WEB_LISTEN", &cfg.Web.Listen) envStr("FELHOM_WEB_PASSWORD_HASH", &cfg.Web.PasswordHash) envStr("FELHOM_PATHS_STACKS_DIR", &cfg.Paths.StacksDir) envStr("FELHOM_PATHS_HDD_PATH", &cfg.Paths.HDDPath) envStr("FELHOM_LOGGING_LEVEL", &cfg.Logging.Level) } func validate(cfg *Config) error { var errs []string if cfg.Customer.ID == "" { errs = append(errs, "customer.id is required") } if cfg.Customer.Domain == "" { errs = append(errs, "customer.domain is required") } switch cfg.Logging.Level { case "debug", "info", "warn", "error": default: errs = append(errs, fmt.Sprintf("logging.level must be debug|info|warn|error, got %q", cfg.Logging.Level)) } if cfg.Monitoring.Thresholds.DiskWarnPercent >= cfg.Monitoring.Thresholds.DiskCritPercent { errs = append(errs, "disk_warn_percent must be less than disk_crit_percent") } if len(errs) > 0 { return fmt.Errorf("validation errors:\n - %s", strings.Join(errs, "\n - ")) } return nil } // IsProtectedStack checks if a stack name is in the protected list. func (cfg *Config) IsProtectedStack(name string) bool { for _, p := range cfg.Stacks.Protected { if strings.EqualFold(p, name) { return true } } return false } // AppLogoURL returns the primary logo URL (SVG). Use AppLogoPNGURL as fallback. func (cfg *Config) AppLogoURL(slug string) string { return fmt.Sprintf("/static/assets/%s-logo.svg", slug) } // AppLogoPNGURL returns the PNG fallback logo URL. func (cfg *Config) AppLogoPNGURL(slug string) string { return fmt.Sprintf("/static/assets/%s-logo.png", slug) } // AppScreenshotURL returns the local URL for an app's screenshot. func (cfg *Config) AppScreenshotURL(slug string, index int) string { return fmt.Sprintf("/static/assets/%s-screenshot-%d.webp", slug, index) } // AppPageURL returns the URL for an app's detail page. // This links to the local controller-hosted app detail page. func (cfg *Config) AppPageURL(slug string) string { return fmt.Sprintf("/apps/%s", slug) }