package settings import ( "crypto/rand" "encoding/json" "fmt" "io" "log" "os" "path/filepath" "strings" "sync" "time" ) // cryptoRandRead is a var so tests can stub it. var cryptoRandRead = func(b []byte) (int, error) { return io.ReadFull(rand.Reader, b) } // Settings holds customer-modifiable overrides and cached state. // Persisted as a single JSON file (settings.json) in the data directory. type Settings struct { mu sync.RWMutex `json:"-"` path string `json:"-"` log *log.Logger `json:"-"` // Auth PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml // Notification preferences (Phase 2 — define struct now, leave empty) Notifications *NotificationPrefs `json:"notifications,omitempty"` // Cached state DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"` // Per-app backup preferences AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"` // Storage paths registry StoragePaths []StoragePath `json:"storage_paths,omitempty"` // Cross-drive restic repo password (auto-generated on first use) CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"` } // AppBackupPrefs holds per-app backup toggle state. type AppBackupPrefs struct { // Existing: includes app data in nightly restic (same drive) Enabled bool `json:"enabled"` // Cross-drive backup to secondary storage CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"` } // CrossDriveBackup configures per-app backup to a secondary drive. type CrossDriveBackup struct { Enabled bool `json:"enabled"` Method string `json:"method"` // "rsync" or "restic" DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1" Schedule string `json:"schedule"` // "daily", "weekly", "manual" // Runtime state (updated by backup runner, persisted for display) LastRun string `json:"last_run,omitempty"` // RFC3339 LastStatus string `json:"last_status,omitempty"` // "ok", "error", "running" LastError string `json:"last_error,omitempty"` LastDuration string `json:"last_duration,omitempty"` // "2m34s" LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB" } // StoragePath represents a registered external storage location. type StoragePath struct { Path string `json:"path"` // e.g., "/mnt/hdd_1" Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB" IsDefault bool `json:"is_default,omitempty"` // new apps use this by default Schedulable bool `json:"schedulable"` // whether new apps can be deployed here AddedAt string `json:"added_at"` // RFC3339 Disconnected bool `json:"disconnected,omitempty"` // true when drive detected as disconnected DisconnectedAt string `json:"disconnected_at,omitempty"` // RFC3339 timestamp of disconnect detection StoppedStacks []string `json:"stopped_stacks,omitempty"` // stacks auto-stopped on disconnect } // NotificationPrefs holds customer notification preferences. type NotificationPrefs struct { Email string `json:"email,omitempty"` EnabledEvents []string `json:"enabled_events,omitempty"` CooldownHours int `json:"cooldown_hours,omitempty"` // default: 6 } // DefaultEnabledEvents are the events enabled by default for new customers. var DefaultEnabledEvents = []string{ "disk_warning", "backup_failed", "update_available", "storage_disconnected", "storage_reconnected", } // DBValidationCache holds cached DB dump validation results. type DBValidationCache struct { ValidatedAt string `json:"validated_at"` // RFC3339 TableCount int `json:"table_count"` HasHeader bool `json:"has_header"` Error string `json:"error,omitempty"` } // Load reads settings from the given file path. // Returns empty Settings if the file doesn't exist (not an error). func Load(path string, logger *log.Logger) (*Settings, error) { s := &Settings{ path: path, log: logger, } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { logger.Printf("[INFO] No settings.json found, using defaults") return s, nil } return nil, fmt.Errorf("reading settings file: %w", err) } if err := json.Unmarshal(data, s); err != nil { return nil, fmt.Errorf("parsing settings file: %w", err) } logger.Printf("[DEBUG] Settings loaded from %s", path) return s, nil } // Save writes settings to disk atomically (write to .tmp, rename). // Caller must hold the write lock or call this from a method that does. func (s *Settings) save() error { data, err := json.MarshalIndent(s, "", " ") if err != nil { return fmt.Errorf("marshaling settings: %w", err) } tmpPath := s.path + ".tmp" if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { return fmt.Errorf("creating settings dir: %w", err) } if err := os.WriteFile(tmpPath, data, 0644); err != nil { os.Remove(tmpPath) // clean up partial file return fmt.Errorf("writing tmp settings: %w", err) } if err := os.Rename(tmpPath, s.path); err != nil { os.Remove(tmpPath) return fmt.Errorf("renaming settings file: %w", err) } s.log.Printf("[DEBUG] Settings saved to %s", s.path) return nil } // GetPasswordHash returns the stored password hash (thread-safe). func (s *Settings) GetPasswordHash() string { s.mu.RLock() defer s.mu.RUnlock() return s.PasswordHash } // SetPasswordHash updates the password hash and saves to disk. func (s *Settings) SetPasswordHash(hash string) error { s.mu.Lock() defer s.mu.Unlock() s.PasswordHash = hash return s.save() } // GetDBValidations returns a copy of the cached DB validations. func (s *Settings) GetDBValidations() map[string]DBValidationCache { s.mu.RLock() defer s.mu.RUnlock() if s.DBValidations == nil { return nil } result := make(map[string]DBValidationCache, len(s.DBValidations)) for k, v := range s.DBValidations { result[k] = v } return result } // SetDBValidation saves a validation result for a dump file and persists to disk. func (s *Settings) SetDBValidation(filename string, cache DBValidationCache) error { s.mu.Lock() defer s.mu.Unlock() if s.DBValidations == nil { s.DBValidations = make(map[string]DBValidationCache) } s.DBValidations[filename] = cache return s.save() } // GetNotificationPrefs returns a copy of the notification preferences. func (s *Settings) GetNotificationPrefs() *NotificationPrefs { s.mu.RLock() defer s.mu.RUnlock() if s.Notifications == nil { return &NotificationPrefs{ EnabledEvents: DefaultEnabledEvents, CooldownHours: 6, } } prefs := *s.Notifications if prefs.CooldownHours == 0 { prefs.CooldownHours = 6 } if prefs.EnabledEvents == nil { prefs.EnabledEvents = DefaultEnabledEvents } // Return a copy of the slice events := make([]string, len(prefs.EnabledEvents)) copy(events, prefs.EnabledEvents) prefs.EnabledEvents = events return &prefs } // SetNotificationPrefs updates notification preferences and saves to disk. // H17: Deep-copies prefs so caller mutations after the call don't affect stored state. func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error { if prefs == nil { return fmt.Errorf("notification preferences cannot be nil") } s.mu.Lock() defer s.mu.Unlock() copy := *prefs if len(prefs.EnabledEvents) > 0 { copy.EnabledEvents = make([]string, len(prefs.EnabledEvents)) for i, e := range prefs.EnabledEvents { copy.EnabledEvents[i] = e } } s.Notifications = © return s.save() } // GetCrossDriveConfig returns the cross-drive backup config for a stack (nil if not set). func (s *Settings) GetCrossDriveConfig(stackName string) *CrossDriveBackup { s.mu.RLock() defer s.mu.RUnlock() if s.AppBackup == nil { return nil } prefs, ok := s.AppBackup[stackName] if !ok || prefs.CrossDrive == nil { return nil } cp := *prefs.CrossDrive return &cp } // SetCrossDriveConfig saves (or clears) the cross-drive backup config for a stack. func (s *Settings) SetCrossDriveConfig(stackName string, cfg *CrossDriveBackup) error { s.mu.Lock() defer s.mu.Unlock() if s.AppBackup == nil { s.AppBackup = make(map[string]AppBackupPrefs) } existing := s.AppBackup[stackName] existing.CrossDrive = cfg s.AppBackup[stackName] = existing return s.save() } // UpdateCrossDriveStatus updates runtime status fields for a cross-drive backup in-place. // fn receives a pointer to the CrossDriveBackup and may mutate it. // If no cross-drive config exists for the stack, does nothing and returns nil. func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveBackup)) error { s.mu.Lock() defer s.mu.Unlock() if s.AppBackup == nil { s.AppBackup = make(map[string]AppBackupPrefs) } existing := s.AppBackup[stackName] if existing.CrossDrive == nil { return nil // don't create config from thin air — just skip status update } fn(existing.CrossDrive) s.AppBackup[stackName] = existing return s.save() } // GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not). func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup { s.mu.RLock() defer s.mu.RUnlock() result := make(map[string]*CrossDriveBackup) for name, prefs := range s.AppBackup { if prefs.CrossDrive != nil { cp := *prefs.CrossDrive result[name] = &cp } } return result } // GetCrossDriveResticPassword returns the cross-drive restic password (read-only). // Returns empty string if not yet generated. func (s *Settings) GetCrossDriveResticPassword() string { s.mu.RLock() defer s.mu.RUnlock() return s.CrossDriveResticPassword } // SetCrossDriveResticPassword sets the cross-drive restic password (e.g., during DR restore). func (s *Settings) SetCrossDriveResticPassword(password string) error { s.mu.Lock() defer s.mu.Unlock() s.CrossDriveResticPassword = password return s.save() } // GetOrCreateCrossDrivePassword returns the cross-drive restic password, // generating and persisting one if it doesn't exist yet. func (s *Settings) GetOrCreateCrossDrivePassword() (string, error) { s.mu.Lock() defer s.mu.Unlock() if s.CrossDriveResticPassword != "" { return s.CrossDriveResticPassword, nil } // Generate a random 32-byte password buf := make([]byte, 32) _, err := cryptoRandRead(buf) if err != nil { return "", fmt.Errorf("generating cross-drive restic password: %w", err) } s.CrossDriveResticPassword = fmt.Sprintf("%x", buf) if err := s.save(); err != nil { return "", err } return s.CrossDriveResticPassword, nil } // --- Storage Paths --- // GetStoragePaths returns a copy of all registered storage paths. func (s *Settings) GetStoragePaths() []StoragePath { s.mu.RLock() defer s.mu.RUnlock() if len(s.StoragePaths) == 0 { return nil } result := make([]StoragePath, len(s.StoragePaths)) copy(result, s.StoragePaths) return result } // GetDefaultStoragePath returns the default storage path string, or "". func (s *Settings) GetDefaultStoragePath() string { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if sp.IsDefault { return sp.Path } } return "" } // GetSchedulableStoragePaths returns paths available for new deployments. func (s *Settings) GetSchedulableStoragePaths() []StoragePath { s.mu.RLock() defer s.mu.RUnlock() var result []StoragePath for _, sp := range s.StoragePaths { if sp.Schedulable { result = append(result, sp) } } return result } // AddStoragePath registers a new storage path. Validation is done by caller. func (s *Settings) AddStoragePath(sp StoragePath) error { s.mu.Lock() defer s.mu.Unlock() if sp.IsDefault { for i := range s.StoragePaths { s.StoragePaths[i].IsDefault = false } } s.StoragePaths = append(s.StoragePaths, sp) return s.save() } // RemoveStoragePath removes a path by its path string. func (s *Settings) RemoveStoragePath(path string) error { s.mu.Lock() defer s.mu.Unlock() var kept []StoragePath for _, sp := range s.StoragePaths { if sp.Path != path { kept = append(kept, sp) } } s.StoragePaths = kept return s.save() } // SetDefaultStoragePath changes which path is the default. func (s *Settings) SetDefaultStoragePath(path string) error { s.mu.Lock() defer s.mu.Unlock() found := false for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].IsDefault = true found = true } else { s.StoragePaths[i].IsDefault = false } } if !found { return fmt.Errorf("storage path %q not found", path) } return s.save() } // SetSchedulable enables/disables a path for new deployments. func (s *Settings) SetSchedulable(path string, schedulable bool) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Schedulable = schedulable return s.save() } } return fmt.Errorf("storage path %q not found", path) } // SetStorageLabel updates the label for a storage path. func (s *Settings) SetStorageLabel(path, label string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Label = label return s.save() } } return fmt.Errorf("storage path %q not found", path) } // AutoDiscoverStoragePaths scans for HDD_PATH values and registers them if none exist. // discoveredPaths are pre-scanned HDD_PATH values from deployed apps' app.yaml. // fallbackHDDPath is the legacy controller.yaml paths.hdd_path (may be empty). func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHDDPath string, logger *log.Logger) { s.mu.Lock() defer s.mu.Unlock() if len(s.StoragePaths) > 0 { return // already configured } seen := make(map[string]bool) var ordered []string for _, p := range discoveredPaths { cleaned := filepath.Clean(p) if cleaned != "" && !seen[cleaned] { seen[cleaned] = true ordered = append(ordered, cleaned) } } if fallbackHDDPath != "" { cleaned := filepath.Clean(fallbackHDDPath) if !seen[cleaned] { seen[cleaned] = true ordered = append(ordered, cleaned) } } for i, path := range ordered { sp := StoragePath{ Path: path, Label: InferStorageLabel(path), IsDefault: i == 0, Schedulable: true, AddedAt: time.Now().UTC().Format(time.RFC3339), } s.StoragePaths = append(s.StoragePaths, sp) } if len(s.StoragePaths) > 0 { if err := s.save(); err != nil { logger.Printf("[ERROR] Failed to save auto-discovered storage paths: %v", err) return } logger.Printf("[INFO] Auto-discovered %d storage path(s)", len(s.StoragePaths)) for _, sp := range s.StoragePaths { logger.Printf("[INFO] %s (%s) default=%v", sp.Path, sp.Label, sp.IsDefault) } } } // InferStorageLabel generates a human-readable label for a storage path. func InferStorageLabel(path string) string { base := filepath.Base(path) if strings.HasPrefix(base, "hdd") || strings.HasPrefix(base, "ssd") || strings.HasPrefix(base, "usb") { return fmt.Sprintf("Külső tárhely (%s)", base) } return fmt.Sprintf("Tárhely (%s)", base) } // SetDisconnected marks a storage path as disconnected (or connected) and records which stacks were stopped. func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Disconnected = disconnected if disconnected { s.StoragePaths[i].DisconnectedAt = time.Now().UTC().Format(time.RFC3339) s.StoragePaths[i].StoppedStacks = stoppedStacks } else { s.StoragePaths[i].DisconnectedAt = "" // Preserve StoppedStacks on reconnect so the UI can offer restart if stoppedStacks != nil { s.StoragePaths[i].StoppedStacks = stoppedStacks } } return s.save() } } return fmt.Errorf("storage path %q not found", path) } // ClearDisconnected marks a path as connected and clears all disconnect-related fields. func (s *Settings) ClearDisconnected(path string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].Disconnected = false s.StoragePaths[i].DisconnectedAt = "" s.StoragePaths[i].StoppedStacks = nil return s.save() } } return fmt.Errorf("storage path %q not found", path) } // IsDisconnected returns whether a storage path is marked as disconnected. func (s *Settings) IsDisconnected(path string) bool { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if sp.Path == path { return sp.Disconnected } } return false } // GetDisconnectedPaths returns a copy of all storage paths that are marked disconnected. func (s *Settings) GetDisconnectedPaths() []StoragePath { s.mu.RLock() defer s.mu.RUnlock() var result []StoragePath for _, sp := range s.StoragePaths { if sp.Disconnected { result = append(result, sp) } } return result } // GetConnectedPaths returns a copy of all storage paths that are NOT disconnected. func (s *Settings) GetConnectedPaths() []StoragePath { s.mu.RLock() defer s.mu.RUnlock() var result []StoragePath for _, sp := range s.StoragePaths { if !sp.Disconnected { result = append(result, sp) } } return result } // GetStoppedStacks returns the list of stacks that were auto-stopped for a storage path. func (s *Settings) GetStoppedStacks(path string) []string { s.mu.RLock() defer s.mu.RUnlock() for _, sp := range s.StoragePaths { if sp.Path == path { if len(sp.StoppedStacks) == 0 { return nil } result := make([]string, len(sp.StoppedStacks)) copy(result, sp.StoppedStacks) return result } } return nil } // ClearStoppedStacks removes the stopped stacks list for a storage path (e.g., after restart). func (s *Settings) ClearStoppedStacks(path string) error { s.mu.Lock() defer s.mu.Unlock() for i := range s.StoragePaths { if s.StoragePaths[i].Path == path { s.StoragePaths[i].StoppedStacks = nil return s.save() } } return fmt.Errorf("storage path %q not found", path) }