d160c6c06d
CRITICAL: - C1: SetAppBackupBulk data loss + nil map panic (settings.go) - C2: UpdateStackConfig nil Env map panic (deploy.go) - C3: ValidateDump missing scanner.Err() check (dbdump.go) HIGH: - H1: nextDailyRun DST bug — use time.Date(day+1) not Add(24h) - H2: Cache Europe/Budapest timezone with sync.Once in scheduler - H3: settings.save() leaks .tmp file on WriteFile failure - H4: SetNotificationPrefs nil pointer panic - H5: appDirSize + getDirSizeBytes ignore Sscanf return value - H6: getDirSizeBytes has no timeout — add 30s context - H7: dbdump.go tmpFile not using defer Close - H8: UpdateCrossDriveStatus misleading comment MEDIUM: - M1: Replace custom containsBytes with strings.Contains - M2: scheduler.Every() validates interval > 0 - M3: executeJob panic recovery now sets LastRun - M4: logPostStartStatus copies env slice before goroutine - M5: Cache timezone in web package via getTimezone() sync.Once Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
551 lines
16 KiB
Go
551 lines
16 KiB
Go
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
|
|
}
|
|
|
|
// 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",
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// IsAppBackupEnabled returns whether backup is enabled for the given stack.
|
|
func (s *Settings) IsAppBackupEnabled(stackName string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.AppBackup == nil {
|
|
return false
|
|
}
|
|
return s.AppBackup[stackName].Enabled
|
|
}
|
|
|
|
// SetAppBackup enables or disables backup for a stack and saves to disk.
|
|
// Preserves existing CrossDrive config.
|
|
func (s *Settings) SetAppBackup(stackName string, enabled bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AppBackup == nil {
|
|
s.AppBackup = make(map[string]AppBackupPrefs)
|
|
}
|
|
existing := s.AppBackup[stackName]
|
|
existing.Enabled = enabled
|
|
s.AppBackup[stackName] = existing
|
|
return s.save()
|
|
}
|
|
|
|
// GetAppBackupMap returns a map of stack_name -> enabled for all app backup prefs.
|
|
func (s *Settings) GetAppBackupMap() map[string]bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.AppBackup == nil {
|
|
return nil
|
|
}
|
|
result := make(map[string]bool, len(s.AppBackup))
|
|
for k, v := range s.AppBackup {
|
|
result[k] = v.Enabled
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SetAppBackupBulk updates backup prefs for all stacks at once and saves to disk.
|
|
// Preserves existing CrossDrive configs and stacks not present in the input.
|
|
func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AppBackup == nil {
|
|
s.AppBackup = make(map[string]AppBackupPrefs)
|
|
}
|
|
for name, enabled := range prefs {
|
|
existing := s.AppBackup[name] // preserves CrossDrive
|
|
existing.Enabled = enabled
|
|
s.AppBackup[name] = existing
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// GetAppBackupPrefs returns the full AppBackupPrefs for a stack.
|
|
func (s *Settings) GetAppBackupPrefs(stackName string) (AppBackupPrefs, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.AppBackup == nil {
|
|
return AppBackupPrefs{}, false
|
|
}
|
|
prefs, ok := s.AppBackup[stackName]
|
|
return prefs, ok
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
}
|