Files
deploy-felhom-compose/controller/internal/config/config.go
T
admin 45f75a916c fix: P2+P3 bug fixes, hardening, and cleanup (18 files)
Bug fixes:
- Add applyEnvOverrides to LoadFromBytes (M05)
- Set state=failed on compose-up failure in selfupdate (M16)
- Clamp usableMB to min 0 in memory check (M22)
- Remove "manual" schedule from triggerAllCrossBackups (M23)
- Add mmcblk device handling for partition paths (M21)
- Fix stripPartition for mmcblk devices (L25)
- Fix TruncateStr for UTF-8 and negative maxLen (L05/L06)
- Fix AllDone to return false for empty restore plans (L14)
- Fix PushOnce to return actual errors (L39)
- Restore pending events on save failure in DrainPendingEvents (M03)
- Add duplicate check in AddStoragePath (M04)
- Call CleanupTempMounts after drive scan (H13)
- Log SetStep save errors (M25)

Hardening:
- Guard scheduler Start() against double-start (M14)
- Acquire mutex in scheduler Stop() before reading cancel (L24)
- Cap log lines parameter to 10000 (L31)
- Require POST for logout (L32)
- Use sync.Once for Server.Close() (L49)
- Panic on crypto/rand.Read failure in setup CSRF (L40)
- Validate Bearer token against Hub API key in CSRF (H16 fix)
- Replace custom hasPrefix with strings.HasPrefix (L13)
- Replace simpleHash with crc32.ChecksumIEEE (L48)

Cleanup:
- Remove dead imageName function (L02)
- Remove dead detectHostIPViaRoute function (L03)
- Rename shadowed copy variable to cp (L07)
- Copy DefaultEnabledEvents in GetNotificationPrefs early return (L09)
- Update BUGHUNT.md with comprehensive audit results

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:47:52 +01:00

346 lines
11 KiB
Go

package config
import (
"crypto/sha256"
"encoding/hex"
"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"`
Hub HubConfig `yaml:"hub"`
SelfUpdate SelfUpdateConfig `yaml:"self_update"`
Notifications NotificationsConfig `yaml:"notifications"`
Logging LoggingConfig `yaml:"logging"`
Assets AssetsConfig `yaml:"assets"`
System SystemConfig `yaml:"system"`
}
type SystemConfig struct {
ReservedMemoryMB int `yaml:"reserved_memory_mb"`
}
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"`
SystemDataPath string `yaml:"system_data_path"`
HDDPath string `yaml:"hdd_path"`
}
type WebConfig struct {
Listen string `yaml:"listen"`
SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode)
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"`
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"`
SystemHealthInterval string `yaml:"system_health_interval"`
Thresholds ThresholdsConfig `yaml:"thresholds"`
}
type PingUUIDsConfig struct {
Heartbeat string `yaml:"heartbeat"`
DBDump string `yaml:"db_dump"`
Backup string `yaml:"backup"`
SystemHealth string `yaml:"system_health"`
BackupIntegrity string `yaml:"backup_integrity"`
}
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"`
AutoUpdateTime string `yaml:"auto_update_time"`
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
SyncEnabled bool `yaml:"sync_enabled"` // Download assets from Hub API
SyncSchedule string `yaml:"sync_schedule"` // Daily sync time (HH:MM), default "05:00"
}
type HubConfig struct {
Enabled bool `yaml:"enabled"`
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
PushInterval string `yaml:"push_interval"`
}
// Load reads and parses the config file, applies defaults, and validates.
func Load(path string) (*Config, error) {
cfg, err := loadAndParse(path)
if err != nil {
return nil, err
}
if err := validate(cfg); err != nil {
return nil, fmt.Errorf("config validation: %w", err)
}
return cfg, nil
}
// LoadPermissive reads and parses the config file, applies defaults, but skips validation.
// Used during setup mode where customer.id and domain may not be set yet.
func LoadPermissive(path string) (*Config, error) {
return loadAndParse(path)
}
// Default returns a Config with all defaults applied. Used when the config file
// is missing or unreadable and the controller needs to enter setup mode.
func Default() *Config {
cfg := &Config{}
applyDefaults(cfg)
return cfg
}
func loadAndParse(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)
return cfg, nil
}
// LoadFromBytes parses YAML config from raw bytes (for validation without file I/O).
func LoadFromBytes(data []byte) (*Config, error) {
expanded := os.ExpandEnv(string(data))
cfg := &Config{}
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
applyDefaults(cfg)
applyEnvOverrides(cfg)
if err := validate(cfg); err != nil {
return nil, err
}
return cfg, nil
}
// FileHash returns the SHA256 hex digest of the config file at the given path.
func FileHash(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
h := sha256.Sum256(data)
return hex.EncodeToString(h[:]), 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.SystemDataPath, "/mnt/sys_drive")
d(&cfg.Web.Listen, ":8080")
d(&cfg.Web.SetupListen, ":8081")
d(&cfg.Git.Branch, "main")
d(&cfg.Git.SyncInterval, "15m")
d(&cfg.Stacks.UpdateWindow, "03:00-05:00")
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.Backup.ResticPasswordFile, "/opt/docker/felhom-controller/data/restic-password")
d(&cfg.Monitoring.HealthchecksBase, "https://status.felhom.eu")
d(&cfg.Monitoring.HealthCheckSchedule, "06:00")
d(&cfg.Monitoring.SystemHealthInterval, "5m")
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.Hub.PushInterval, "15m")
d(&cfg.SelfUpdate.CheckInterval, "6h")
d(&cfg.SelfUpdate.Image, "gitea.dooplex.hu/admin/felhom-controller")
d(&cfg.SelfUpdate.AutoUpdateTime, "04:30")
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")
d(&cfg.Assets.SyncSchedule, "05:00")
di(&cfg.System.ReservedMemoryMB, 384)
}
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)
envStr("FELHOM_MONITORING_SYSTEM_HEALTH_INTERVAL", &cfg.Monitoring.SystemHealthInterval)
}
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)
}