95c821deb2
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1044 lines
32 KiB
Go
1044 lines
32 KiB
Go
package settings
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// 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:"-"`
|
|
debug bool `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"`
|
|
|
|
// Hub verification state
|
|
HubVerified bool `json:"hub_verified,omitempty"`
|
|
HubVerifiedAt string `json:"hub_verified_at,omitempty"` // RFC3339
|
|
HubLastCheck string `json:"hub_last_check,omitempty"` // RFC3339
|
|
|
|
// Recovery credentials (saved from setup wizard input)
|
|
RetrievalPassword string `json:"retrieval_password,omitempty"`
|
|
|
|
// Pending events (queued for next Hub push)
|
|
PendingEvents []PendingEvent `json:"pending_events,omitempty"`
|
|
|
|
// Geo-restriction settings (Cloudflare WAF rules)
|
|
GeoRestriction *GeoRestriction `json:"geo_restriction,omitempty"`
|
|
|
|
// App-to-app integration state (e.g., "onlyoffice:filebrowser" → state)
|
|
Integrations map[string]IntegrationState `json:"integrations,omitempty"`
|
|
}
|
|
|
|
// IntegrationState holds the state of a provider:target integration pair.
|
|
type IntegrationState struct {
|
|
Enabled bool `json:"enabled"`
|
|
EnabledAt string `json:"enabled_at,omitempty"` // RFC3339
|
|
Status string `json:"status,omitempty"` // "active", "error", "disabled", "provider_stopped", "target_unavailable"
|
|
LastError string `json:"last_error,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
|
|
Decommissioned bool `json:"decommissioned,omitempty"` // true when drive data migrated to another
|
|
DecommissionedAt string `json:"decommissioned_at,omitempty"` // RFC3339 timestamp
|
|
MigratedTo string `json:"migrated_to,omitempty"` // path of target drive
|
|
}
|
|
|
|
// 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{
|
|
"backup_failed",
|
|
"db_dump_failed",
|
|
"disk_warning",
|
|
"disk_critical",
|
|
"storage_disconnected",
|
|
"node_down",
|
|
"health_critical",
|
|
"expected_backup_missed",
|
|
"expected_dbdump_missed",
|
|
}
|
|
|
|
// PendingEvent is an event queued for the next Hub push cycle.
|
|
type PendingEvent struct {
|
|
EventType string `json:"event_type"`
|
|
Severity string `json:"severity"`
|
|
Message string `json:"message"`
|
|
Details string `json:"details"` // JSON string
|
|
CreatedAt string `json:"created_at"` // RFC3339
|
|
}
|
|
|
|
// GeoRestriction holds global and per-app geo-restriction settings.
|
|
type GeoRestriction struct {
|
|
Enabled bool `json:"enabled"`
|
|
AllowedCountries []string `json:"allowed_countries"`
|
|
AppOverrides map[string]AppGeoOverride `json:"app_overrides,omitempty"`
|
|
|
|
// Sync state (updated by geo sync manager)
|
|
LastSync string `json:"last_sync,omitempty"` // RFC3339
|
|
LastSyncError string `json:"last_sync_error,omitempty"`
|
|
ZoneID string `json:"zone_id,omitempty"` // cached Cloudflare zone ID
|
|
RulesetID string `json:"ruleset_id,omitempty"` // cached Cloudflare ruleset ID
|
|
}
|
|
|
|
// AppGeoOverride holds per-app country override.
|
|
type AppGeoOverride struct {
|
|
AllowedCountries []string `json:"allowed_countries"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// SetDebug enables or disables debug logging for settings operations.
|
|
func (s *Settings) SetDebug(debug bool) {
|
|
s.debug = debug
|
|
}
|
|
|
|
// 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)
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] loaded: storage_paths=%d integrations=%d pending_events=%d",
|
|
len(s.StoragePaths), len(s.Integrations), len(s.PendingEvents))
|
|
}
|
|
s.migrateResticToRsync()
|
|
return s, nil
|
|
}
|
|
|
|
// migrateResticToRsync converts any cross-drive backup configs using restic to rsync.
|
|
// Called once during Load() before the mutex is exposed.
|
|
func (s *Settings) migrateResticToRsync() {
|
|
changed := false
|
|
for name, prefs := range s.AppBackup {
|
|
if prefs.CrossDrive != nil && prefs.CrossDrive.Method == "restic" {
|
|
prefs.CrossDrive.Method = "rsync"
|
|
s.AppBackup[name] = prefs
|
|
if s.log != nil {
|
|
s.log.Printf("[INFO] Migrated cross-drive backup for %s from restic to rsync", name)
|
|
}
|
|
changed = true
|
|
}
|
|
}
|
|
if changed {
|
|
if err := s.save(); err != nil && s.log != nil {
|
|
s.log.Printf("[ERROR] Failed to save restic→rsync migration: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] saved to %s (%d bytes)", s.path, len(data))
|
|
}
|
|
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 {
|
|
events := make([]string, len(DefaultEnabledEvents))
|
|
copy(events, DefaultEnabledEvents)
|
|
return &NotificationPrefs{
|
|
EnabledEvents: events,
|
|
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()
|
|
cp := *prefs
|
|
if len(prefs.EnabledEvents) > 0 {
|
|
cp.EnabledEvents = make([]string, len(prefs.EnabledEvents))
|
|
for i, e := range prefs.EnabledEvents {
|
|
cp.EnabledEvents[i] = e
|
|
}
|
|
}
|
|
s.Notifications = &cp
|
|
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
|
|
}
|
|
|
|
// NOTE: GetCrossDriveResticPassword, SetCrossDriveResticPassword, and
|
|
// GetOrCreateCrossDrivePassword were removed in the Tier 2 restic deprecation.
|
|
// The CrossDriveResticPassword field is kept in the struct for backward-compat
|
|
// JSON loading but is no longer used.
|
|
|
|
// --- 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 ""
|
|
}
|
|
|
|
// GetStorageLabel returns the label for a storage path, or the base name if not found.
|
|
func (s *Settings) GetStorageLabel(path string) string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Path == path && sp.Label != "" {
|
|
return sp.Label
|
|
}
|
|
}
|
|
return filepath.Base(path)
|
|
}
|
|
|
|
// 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 && !sp.Decommissioned {
|
|
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 s.debug {
|
|
s.log.Printf("[DEBUG] [settings] AddStoragePath path=%q label=%q default=%v", sp.Path, sp.Label, sp.IsDefault)
|
|
}
|
|
for _, existing := range s.StoragePaths {
|
|
if existing.Path == sp.Path {
|
|
return fmt.Errorf("storage path %q already registered", sp.Path)
|
|
}
|
|
}
|
|
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()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] RemoveStoragePath path=%q", path)
|
|
}
|
|
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 s.debug {
|
|
s.log.Printf("[DEBUG] [settings] AutoDiscoverStoragePaths discovered=%v fallback=%q existing=%d", discoveredPaths, fallbackHDDPath, len(s.StoragePaths))
|
|
}
|
|
|
|
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()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] SetDisconnected path=%q disconnected=%v stopped_stacks=%d", path, disconnected, len(stoppedStacks))
|
|
}
|
|
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 and NOT decommissioned.
|
|
func (s *Settings) GetConnectedPaths() []StoragePath {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var result []StoragePath
|
|
for _, sp := range s.StoragePaths {
|
|
if !sp.Disconnected && !sp.Decommissioned {
|
|
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)
|
|
}
|
|
|
|
// SetDecommissioned marks a storage path as decommissioned with migration target.
|
|
// Clears IsDefault and Schedulable.
|
|
func (s *Settings) SetDecommissioned(path, migratedTo string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] SetDecommissioned path=%q migrated_to=%q", path, migratedTo)
|
|
}
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].Decommissioned = true
|
|
s.StoragePaths[i].DecommissionedAt = time.Now().UTC().Format(time.RFC3339)
|
|
s.StoragePaths[i].MigratedTo = migratedTo
|
|
s.StoragePaths[i].IsDefault = false
|
|
s.StoragePaths[i].Schedulable = false
|
|
return s.save()
|
|
}
|
|
}
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
|
|
// ClearDecommissioned removes the decommissioned state from a storage path.
|
|
func (s *Settings) ClearDecommissioned(path string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.StoragePaths {
|
|
if s.StoragePaths[i].Path == path {
|
|
s.StoragePaths[i].Decommissioned = false
|
|
s.StoragePaths[i].DecommissionedAt = ""
|
|
s.StoragePaths[i].MigratedTo = ""
|
|
return s.save()
|
|
}
|
|
}
|
|
return fmt.Errorf("storage path %q not found", path)
|
|
}
|
|
|
|
// IsDecommissioned returns whether a storage path is marked as decommissioned.
|
|
func (s *Settings) IsDecommissioned(path string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Path == path {
|
|
return sp.Decommissioned
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetDecommissionedPaths returns a copy of all decommissioned storage paths.
|
|
func (s *Settings) GetDecommissionedPaths() []StoragePath {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var result []StoragePath
|
|
for _, sp := range s.StoragePaths {
|
|
if sp.Decommissioned {
|
|
result = append(result, sp)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- Hub Verification ---
|
|
|
|
// GetHubVerified returns the hub verification state.
|
|
func (s *Settings) GetHubVerified() (verified bool, verifiedAt string) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.HubVerified, s.HubVerifiedAt
|
|
}
|
|
|
|
// SetHubVerified updates the hub verification state and saves to disk.
|
|
func (s *Settings) SetHubVerified(verified bool, at time.Time) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.HubVerified = verified
|
|
s.HubVerifiedAt = at.UTC().Format(time.RFC3339)
|
|
s.HubLastCheck = at.UTC().Format(time.RFC3339)
|
|
return s.save()
|
|
}
|
|
|
|
// SetHubLastCheck updates the last Hub check timestamp without changing verification status.
|
|
func (s *Settings) SetHubLastCheck(at time.Time) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.HubLastCheck = at.UTC().Format(time.RFC3339)
|
|
return s.save()
|
|
}
|
|
|
|
// IsLimitedMode returns true if the controller should operate in limited mode
|
|
// (new deployments blocked). This happens when:
|
|
// - Never verified AND >7 days since controller started, OR
|
|
// - Hub explicitly set customer as blocked (HubVerified=false after a successful check)
|
|
func (s *Settings) IsLimitedMode() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if s.HubVerified {
|
|
return false
|
|
}
|
|
|
|
// If we have a last check timestamp and it says not verified, limited mode
|
|
if s.HubLastCheck != "" {
|
|
return true
|
|
}
|
|
|
|
// Never checked yet — check if grace period (7 days) expired
|
|
if s.HubVerifiedAt == "" {
|
|
// No verification timestamp at all — not yet in limited mode (grace period from startup)
|
|
return false
|
|
}
|
|
t, err := time.Parse(time.RFC3339, s.HubVerifiedAt)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return time.Since(t) > 7*24*time.Hour
|
|
}
|
|
|
|
// --- Retrieval Password ---
|
|
|
|
// GetRetrievalPassword returns the stored retrieval password (thread-safe).
|
|
func (s *Settings) GetRetrievalPassword() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.RetrievalPassword
|
|
}
|
|
|
|
// SetRetrievalPassword updates the retrieval password and saves to disk.
|
|
func (s *Settings) SetRetrievalPassword(password string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.RetrievalPassword = password
|
|
return s.save()
|
|
}
|
|
|
|
// --- Pending Events ---
|
|
|
|
// AddPendingEvent queues an event for the next Hub push cycle.
|
|
func (s *Settings) AddPendingEvent(event PendingEvent) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] AddPendingEvent type=%q severity=%q", event.EventType, event.Severity)
|
|
}
|
|
s.PendingEvents = append(s.PendingEvents, event)
|
|
return s.save()
|
|
}
|
|
|
|
// DrainPendingEvents returns and clears all pending events (thread-safe).
|
|
func (s *Settings) DrainPendingEvents() []PendingEvent {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if len(s.PendingEvents) == 0 {
|
|
return nil
|
|
}
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] DrainPendingEvents count=%d", len(s.PendingEvents))
|
|
}
|
|
events := make([]PendingEvent, len(s.PendingEvents))
|
|
copy(events, s.PendingEvents)
|
|
s.PendingEvents = nil
|
|
if err := s.save(); err != nil {
|
|
s.log.Printf("[ERROR] Failed to save after draining pending events: %v — restoring events", err)
|
|
s.PendingEvents = events
|
|
return nil
|
|
}
|
|
return events
|
|
}
|
|
|
|
// --- Geo-Restriction ---
|
|
|
|
// GetGeoRestriction returns a deep copy of the geo-restriction settings.
|
|
func (s *Settings) GetGeoRestriction() *GeoRestriction {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.GeoRestriction == nil {
|
|
return nil
|
|
}
|
|
geo := *s.GeoRestriction
|
|
if len(s.GeoRestriction.AllowedCountries) > 0 {
|
|
geo.AllowedCountries = make([]string, len(s.GeoRestriction.AllowedCountries))
|
|
copy(geo.AllowedCountries, s.GeoRestriction.AllowedCountries)
|
|
}
|
|
if len(s.GeoRestriction.AppOverrides) > 0 {
|
|
geo.AppOverrides = make(map[string]AppGeoOverride, len(s.GeoRestriction.AppOverrides))
|
|
for k, v := range s.GeoRestriction.AppOverrides {
|
|
ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
|
|
copy(ov.AllowedCountries, v.AllowedCountries)
|
|
geo.AppOverrides[k] = ov
|
|
}
|
|
}
|
|
return &geo
|
|
}
|
|
|
|
// SetGeoRestriction replaces the entire geo-restriction config and saves to disk.
|
|
func (s *Settings) SetGeoRestriction(geo *GeoRestriction) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
if geo == nil {
|
|
s.log.Printf("[DEBUG] [settings] SetGeoRestriction geo=nil (clearing)")
|
|
} else {
|
|
s.log.Printf("[DEBUG] [settings] SetGeoRestriction enabled=%v countries=%d", geo.Enabled, len(geo.AllowedCountries))
|
|
}
|
|
}
|
|
if geo == nil {
|
|
s.GeoRestriction = nil
|
|
return s.save()
|
|
}
|
|
cp := *geo
|
|
if len(geo.AllowedCountries) > 0 {
|
|
cp.AllowedCountries = make([]string, len(geo.AllowedCountries))
|
|
copy(cp.AllowedCountries, geo.AllowedCountries)
|
|
}
|
|
if len(geo.AppOverrides) > 0 {
|
|
cp.AppOverrides = make(map[string]AppGeoOverride, len(geo.AppOverrides))
|
|
for k, v := range geo.AppOverrides {
|
|
ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
|
|
copy(ov.AllowedCountries, v.AllowedCountries)
|
|
cp.AppOverrides[k] = ov
|
|
}
|
|
}
|
|
s.GeoRestriction = &cp
|
|
return s.save()
|
|
}
|
|
|
|
// SetGeoAppOverride sets a per-app geo override. Creates the GeoRestriction if nil.
|
|
// Pass override=nil to remove the override (same as RemoveGeoAppOverride).
|
|
func (s *Settings) SetGeoAppOverride(appName string, override *AppGeoOverride) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if override == nil {
|
|
// nil override = remove (fall back to global)
|
|
if s.GeoRestriction != nil && s.GeoRestriction.AppOverrides != nil {
|
|
delete(s.GeoRestriction.AppOverrides, appName)
|
|
}
|
|
return s.save()
|
|
}
|
|
if s.GeoRestriction == nil {
|
|
s.GeoRestriction = &GeoRestriction{AllowedCountries: []string{"HU"}}
|
|
}
|
|
if s.GeoRestriction.AppOverrides == nil {
|
|
s.GeoRestriction.AppOverrides = make(map[string]AppGeoOverride)
|
|
}
|
|
ov := AppGeoOverride{AllowedCountries: make([]string, len(override.AllowedCountries))}
|
|
copy(ov.AllowedCountries, override.AllowedCountries)
|
|
s.GeoRestriction.AppOverrides[appName] = ov
|
|
return s.save()
|
|
}
|
|
|
|
// RemoveGeoAppOverride removes a per-app override (app falls back to global).
|
|
func (s *Settings) RemoveGeoAppOverride(appName string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.GeoRestriction == nil || s.GeoRestriction.AppOverrides == nil {
|
|
return nil
|
|
}
|
|
delete(s.GeoRestriction.AppOverrides, appName)
|
|
return s.save()
|
|
}
|
|
|
|
// SetGeoSyncState updates the geo sync status fields.
|
|
func (s *Settings) SetGeoSyncState(zoneID, rulesetID, syncError string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.GeoRestriction == nil {
|
|
return nil
|
|
}
|
|
s.GeoRestriction.LastSync = time.Now().UTC().Format(time.RFC3339)
|
|
s.GeoRestriction.LastSyncError = syncError
|
|
if zoneID != "" {
|
|
s.GeoRestriction.ZoneID = zoneID
|
|
}
|
|
if rulesetID != "" {
|
|
s.GeoRestriction.RulesetID = rulesetID
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// --- App-to-app integrations ---
|
|
|
|
// GetIntegrationState returns the state for a specific integration key (e.g., "onlyoffice:filebrowser").
|
|
func (s *Settings) GetIntegrationState(key string) (IntegrationState, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.Integrations == nil {
|
|
return IntegrationState{}, false
|
|
}
|
|
state, ok := s.Integrations[key]
|
|
return state, ok
|
|
}
|
|
|
|
// SetIntegrationState updates (or creates) the state for a single integration key.
|
|
func (s *Settings) SetIntegrationState(key string, state IntegrationState) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.debug {
|
|
s.log.Printf("[DEBUG] [settings] SetIntegrationState key=%q status=%q enabled=%v", key, state.Status, state.Enabled)
|
|
}
|
|
if s.Integrations == nil {
|
|
s.Integrations = make(map[string]IntegrationState)
|
|
}
|
|
s.Integrations[key] = state
|
|
return s.save()
|
|
}
|
|
|
|
// RemoveIntegrationState removes an integration key entirely.
|
|
func (s *Settings) RemoveIntegrationState(key string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.Integrations != nil {
|
|
delete(s.Integrations, key)
|
|
}
|
|
return s.save()
|
|
}
|
|
|
|
// GetIntegrationsForProvider returns all integration states where key starts with "provider:".
|
|
func (s *Settings) GetIntegrationsForProvider(provider string) map[string]IntegrationState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
prefix := provider + ":"
|
|
result := make(map[string]IntegrationState)
|
|
for k, v := range s.Integrations {
|
|
if strings.HasPrefix(k, prefix) {
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetIntegrationsForTarget returns all integration states where key ends with ":target".
|
|
func (s *Settings) GetIntegrationsForTarget(target string) map[string]IntegrationState {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
suffix := ":" + target
|
|
result := make(map[string]IntegrationState)
|
|
for k, v := range s.Integrations {
|
|
if strings.HasSuffix(k, suffix) {
|
|
result[k] = v
|
|
}
|
|
}
|
|
return result
|
|
}
|