Files
pulse/internal/config/persistence.go
T
rcourtman c3deb6170e fix: prevent catastrophic data loss from encryption key regeneration
CRITICAL FIX: Prevents nodes.enc configuration from being permanently lost
when decryption fails due to encryption key regeneration or corruption.

Root Cause Analysis:
1. If .encryption.key is deleted/regenerated, existing .enc files become unreadable
2. Previous code would fail to decrypt, try backup (also fails), then return error
3. This left NO nodes.enc file on disk
4. Next startup would see no .enc files and happily generate a new encryption key
5. User's node configuration was permanently lost

Changes Made:

1. **persistence.go (lines 600-645)**: When decryption fails for BOTH main file
   and backup, instead of returning error and leaving no file:
   - Log CRITICAL error with clear message about encryption key issue
   - Move corrupted file to timestamped .corrupted file for forensics
   - Create EMPTY but VALID encrypted nodes.enc file
   - Return empty config so system can start
   - This prevents encryption key regeneration on next startup

2. **crypto.go (lines 93-121)**: Enhanced encryption key generation checks:
   - Now checks for nodes.enc* (including .backup, .corrupted files)
   - Uses glob patterns to find ANY encrypted file remnants
   - Refuses to generate new key if ANY .enc* files exist
   - Provides clear error message listing all found files
   - Forces manual intervention before allowing key regeneration

Benefits:
- System can still start even if decryption fails
- Corrupted files are preserved with timestamps for forensic analysis
- Encryption key cannot be silently regenerated if ANY encrypted data exists
- Clear, prominent error logging helps diagnose the root cause
- User is forced to manually address the issue rather than silently losing data

This should prevent the recurring issue where node configurations mysteriously
disappear, requiring manual reconfiguration through the UI.
2025-10-01 18:52:10 +00:00

965 lines
29 KiB
Go

package config
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/crypto"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rs/zerolog/log"
)
// ConfigPersistence handles saving and loading configuration
type ConfigPersistence struct {
mu sync.RWMutex
configDir string
alertFile string
emailFile string
webhookFile string
nodesFile string
systemFile string
oidcFile string
crypto *crypto.CryptoManager
}
// NewConfigPersistence creates a new config persistence manager
func NewConfigPersistence(configDir string) *ConfigPersistence {
if configDir == "" {
configDir = "/etc/pulse"
}
// Initialize crypto manager
cryptoMgr, err := crypto.NewCryptoManager()
if err != nil {
log.Error().Err(err).Msg("Failed to initialize crypto manager, using unencrypted storage")
cryptoMgr = nil
}
cp := &ConfigPersistence{
configDir: configDir,
alertFile: filepath.Join(configDir, "alerts.json"),
emailFile: filepath.Join(configDir, "email.enc"),
webhookFile: filepath.Join(configDir, "webhooks.enc"),
nodesFile: filepath.Join(configDir, "nodes.enc"),
systemFile: filepath.Join(configDir, "system.json"),
oidcFile: filepath.Join(configDir, "oidc.enc"),
crypto: cryptoMgr,
}
log.Debug().
Str("configDir", configDir).
Str("systemFile", cp.systemFile).
Str("nodesFile", cp.nodesFile).
Bool("encryptionEnabled", cryptoMgr != nil).
Msg("Config persistence initialized")
return cp
}
// EnsureConfigDir ensures the configuration directory exists
func (c *ConfigPersistence) EnsureConfigDir() error {
return os.MkdirAll(c.configDir, 0700)
}
// SaveAlertConfig saves alert configuration to file
func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error {
c.mu.Lock()
defer c.mu.Unlock()
// Ensure critical defaults are set before saving
if config.StorageDefault.Trigger <= 0 {
config.StorageDefault.Trigger = 85
config.StorageDefault.Clear = 80
}
if config.MinimumDelta <= 0 {
margin := config.HysteresisMargin
if margin <= 0 {
margin = 5.0
}
for id, override := range config.Overrides {
if override.Usage != nil {
if override.Usage.Clear <= 0 {
override.Usage.Clear = override.Usage.Trigger - margin
if override.Usage.Clear < 0 {
override.Usage.Clear = 0
}
}
config.Overrides[id] = override
}
}
config.MinimumDelta = 2.0
}
if config.SuppressionWindow <= 0 {
config.SuppressionWindow = 5
}
if config.HysteresisMargin <= 0 {
config.HysteresisMargin = 5.0
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
if err := c.EnsureConfigDir(); err != nil {
return err
}
if err := os.WriteFile(c.alertFile, data, 0600); err != nil {
return err
}
log.Info().Str("file", c.alertFile).Msg("Alert configuration saved")
return nil
}
// LoadAlertConfig loads alert configuration from file
func (c *ConfigPersistence) LoadAlertConfig() (*alerts.AlertConfig, error) {
c.mu.RLock()
defer c.mu.RUnlock()
data, err := os.ReadFile(c.alertFile)
if err != nil {
if os.IsNotExist(err) {
// Return default config if file doesn't exist
return &alerts.AlertConfig{
Enabled: true,
GuestDefaults: alerts.ThresholdConfig{
CPU: &alerts.HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80},
Disk: &alerts.HysteresisThreshold{Trigger: 90, Clear: 85},
},
NodeDefaults: alerts.ThresholdConfig{
CPU: &alerts.HysteresisThreshold{Trigger: 80, Clear: 75},
Memory: &alerts.HysteresisThreshold{Trigger: 85, Clear: 80},
Disk: &alerts.HysteresisThreshold{Trigger: 90, Clear: 85},
},
StorageDefault: alerts.HysteresisThreshold{Trigger: 85, Clear: 80},
MinimumDelta: 2.0,
SuppressionWindow: 5,
HysteresisMargin: 5.0,
Overrides: make(map[string]alerts.ThresholdConfig),
}, nil
}
return nil, err
}
var config alerts.AlertConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
// For empty config files ({}), enable alerts by default
// This handles the case where the file exists but is empty
if string(data) == "{}" {
config.Enabled = true
}
if config.StorageDefault.Trigger <= 0 {
config.StorageDefault.Trigger = 85
config.StorageDefault.Clear = 80
}
if config.MinimumDelta <= 0 {
config.MinimumDelta = 2.0
}
if config.SuppressionWindow <= 0 {
config.SuppressionWindow = 5
}
if config.HysteresisMargin <= 0 {
config.HysteresisMargin = 5.0
}
// Migration: Set I/O metrics to Off (0) if they have the old default values
// This helps existing users avoid noisy I/O alerts
if config.GuestDefaults.DiskRead != nil && config.GuestDefaults.DiskRead.Trigger == 150 {
config.GuestDefaults.DiskRead = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}
}
if config.GuestDefaults.DiskWrite != nil && config.GuestDefaults.DiskWrite.Trigger == 150 {
config.GuestDefaults.DiskWrite = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}
}
if config.GuestDefaults.NetworkIn != nil && config.GuestDefaults.NetworkIn.Trigger == 200 {
config.GuestDefaults.NetworkIn = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}
}
if config.GuestDefaults.NetworkOut != nil && config.GuestDefaults.NetworkOut.Trigger == 200 {
config.GuestDefaults.NetworkOut = &alerts.HysteresisThreshold{Trigger: 0, Clear: 0}
}
log.Info().
Str("file", c.alertFile).
Bool("enabled", config.Enabled).
Msg("Alert configuration loaded")
return &config, nil
}
// SaveEmailConfig saves email configuration to file (encrypted)
func (c *ConfigPersistence) SaveEmailConfig(config notifications.EmailConfig) error {
c.mu.Lock()
defer c.mu.Unlock()
// Marshal to JSON first
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
if err := c.EnsureConfigDir(); err != nil {
return err
}
// Encrypt if crypto manager is available
if c.crypto != nil {
encrypted, err := c.crypto.Encrypt(data)
if err != nil {
return err
}
data = encrypted
}
// Save with restricted permissions (owner read/write only)
if err := os.WriteFile(c.emailFile, data, 0600); err != nil {
return err
}
log.Info().
Str("file", c.emailFile).
Bool("encrypted", c.crypto != nil).
Msg("Email configuration saved")
return nil
}
// LoadEmailConfig loads email configuration from file (decrypts if encrypted)
func (c *ConfigPersistence) LoadEmailConfig() (*notifications.EmailConfig, error) {
c.mu.RLock()
defer c.mu.RUnlock()
data, err := os.ReadFile(c.emailFile)
if err != nil {
if os.IsNotExist(err) {
// Return empty config if encrypted file doesn't exist
return &notifications.EmailConfig{
Enabled: false,
SMTPPort: 587,
TLS: true,
To: []string{},
}, nil
}
return nil, err
}
// Decrypt if crypto manager is available
if c.crypto != nil {
decrypted, err := c.crypto.Decrypt(data)
if err != nil {
return nil, err
}
data = decrypted
}
var config notifications.EmailConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
log.Info().
Str("file", c.emailFile).
Bool("encrypted", c.crypto != nil).
Msg("Email configuration loaded")
return &config, nil
}
// SaveWebhooks saves webhook configurations to file
func (c *ConfigPersistence) SaveWebhooks(webhooks []notifications.WebhookConfig) error {
c.mu.Lock()
defer c.mu.Unlock()
data, err := json.MarshalIndent(webhooks, "", " ")
if err != nil {
return err
}
if err := c.EnsureConfigDir(); err != nil {
return err
}
// Encrypt if crypto manager is available
if c.crypto != nil {
encrypted, err := c.crypto.Encrypt(data)
if err != nil {
return err
}
data = encrypted
}
if err := os.WriteFile(c.webhookFile, data, 0600); err != nil {
return err
}
log.Info().Str("file", c.webhookFile).
Int("count", len(webhooks)).
Bool("encrypted", c.crypto != nil).
Msg("Webhooks saved")
return nil
}
// LoadWebhooks loads webhook configurations from file (decrypts if encrypted)
func (c *ConfigPersistence) LoadWebhooks() ([]notifications.WebhookConfig, error) {
c.mu.RLock()
defer c.mu.RUnlock()
// First try to load from encrypted file
data, err := os.ReadFile(c.webhookFile)
if err != nil {
if os.IsNotExist(err) {
// Check for legacy unencrypted file
legacyFile := filepath.Join(c.configDir, "webhooks.json")
legacyData, legacyErr := os.ReadFile(legacyFile)
if legacyErr == nil {
// Legacy file exists, parse it
var webhooks []notifications.WebhookConfig
if err := json.Unmarshal(legacyData, &webhooks); err == nil {
log.Info().
Str("file", legacyFile).
Int("count", len(webhooks)).
Msg("Found unencrypted webhooks - migration needed")
// Return the loaded webhooks - migration will be handled by caller
return webhooks, nil
}
}
// No webhooks file exists
return []notifications.WebhookConfig{}, nil
}
return nil, err
}
// Decrypt if crypto manager is available
if c.crypto != nil {
decrypted, err := c.crypto.Decrypt(data)
if err != nil {
// Try parsing as plain JSON (migration case)
var webhooks []notifications.WebhookConfig
if jsonErr := json.Unmarshal(data, &webhooks); jsonErr == nil {
log.Info().
Str("file", c.webhookFile).
Int("count", len(webhooks)).
Msg("Loaded unencrypted webhooks (will encrypt on next save)")
return webhooks, nil
}
return nil, fmt.Errorf("failed to decrypt webhooks: %w", err)
}
data = decrypted
}
var webhooks []notifications.WebhookConfig
if err := json.Unmarshal(data, &webhooks); err != nil {
return nil, err
}
log.Info().
Str("file", c.webhookFile).
Int("count", len(webhooks)).
Bool("encrypted", c.crypto != nil).
Msg("Webhooks loaded")
return webhooks, nil
}
// MigrateWebhooksIfNeeded checks for legacy webhooks.json and migrates to encrypted format
func (c *ConfigPersistence) MigrateWebhooksIfNeeded() error {
// Check if encrypted file already exists
if _, err := os.Stat(c.webhookFile); err == nil {
// Encrypted file exists, no migration needed
return nil
}
// Check for legacy unencrypted file
legacyFile := filepath.Join(c.configDir, "webhooks.json")
legacyData, err := os.ReadFile(legacyFile)
if err != nil {
if os.IsNotExist(err) {
// No legacy file, nothing to migrate
return nil
}
return fmt.Errorf("failed to read legacy webhooks file: %w", err)
}
// Parse legacy webhooks
var webhooks []notifications.WebhookConfig
if err := json.Unmarshal(legacyData, &webhooks); err != nil {
return fmt.Errorf("failed to parse legacy webhooks: %w", err)
}
log.Info().
Str("from", legacyFile).
Str("to", c.webhookFile).
Int("count", len(webhooks)).
Msg("Migrating webhooks to encrypted format")
// Save to encrypted file
if err := c.SaveWebhooks(webhooks); err != nil {
return fmt.Errorf("failed to save encrypted webhooks: %w", err)
}
// Create backup of original file
backupFile := legacyFile + ".backup"
if err := os.Rename(legacyFile, backupFile); err != nil {
log.Warn().Err(err).Msg("Failed to rename legacy webhooks file to backup")
} else {
log.Info().Str("backup", backupFile).Msg("Legacy webhooks file backed up")
}
return nil
}
// NodesConfig represents the saved nodes configuration
type NodesConfig struct {
PVEInstances []PVEInstance `json:"pveInstances"`
PBSInstances []PBSInstance `json:"pbsInstances"`
}
// SystemSettings represents system configuration settings
type SystemSettings struct {
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
PBSPollingInterval int `json:"pbsPollingInterval"` // PBS polling interval in seconds
BackendPort int `json:"backendPort,omitempty"`
FrontendPort int `json:"frontendPort,omitempty"`
AllowedOrigins string `json:"allowedOrigins,omitempty"`
ConnectionTimeout int `json:"connectionTimeout,omitempty"`
UpdateChannel string `json:"updateChannel,omitempty"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled"` // Removed omitempty so false is saved
AutoUpdateCheckInterval int `json:"autoUpdateCheckInterval,omitempty"`
AutoUpdateTime string `json:"autoUpdateTime,omitempty"`
LogLevel string `json:"logLevel,omitempty"`
DiscoveryEnabled bool `json:"discoveryEnabled"`
DiscoverySubnet string `json:"discoverySubnet,omitempty"`
Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default
AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
// APIToken removed - now handled via .env file only
}
// SaveNodesConfig saves nodes configuration to file (encrypted)
func (c *ConfigPersistence) SaveNodesConfig(pveInstances []PVEInstance, pbsInstances []PBSInstance) error {
return c.saveNodesConfig(pveInstances, pbsInstances, false)
}
// SaveNodesConfigAllowEmpty saves nodes configuration even when all nodes are removed.
// Use sparingly for explicit administrative actions (e.g. deleting the final node).
func (c *ConfigPersistence) SaveNodesConfigAllowEmpty(pveInstances []PVEInstance, pbsInstances []PBSInstance) error {
return c.saveNodesConfig(pveInstances, pbsInstances, true)
}
func (c *ConfigPersistence) saveNodesConfig(pveInstances []PVEInstance, pbsInstances []PBSInstance, allowEmpty bool) error {
// CRITICAL: Prevent saving empty nodes when in mock mode
// Mock mode should NEVER modify real node configuration
if mock.IsMockEnabled() {
log.Warn().Msg("Skipping nodes save - mock mode is enabled")
return nil // Silently succeed to prevent errors but don't save
}
c.mu.Lock()
defer c.mu.Unlock()
// CRITICAL: Never save empty nodes configuration
// This prevents data loss from accidental wipes
if !allowEmpty && len(pveInstances) == 0 && len(pbsInstances) == 0 {
// Check if we're replacing existing non-empty config
if existing, err := c.LoadNodesConfig(); err == nil && existing != nil {
if len(existing.PVEInstances) > 0 || len(existing.PBSInstances) > 0 {
log.Error().
Int("existing_pve", len(existing.PVEInstances)).
Int("existing_pbs", len(existing.PBSInstances)).
Msg("BLOCKED attempt to save empty nodes config - would delete existing nodes!")
return fmt.Errorf("refusing to save empty nodes config when %d nodes exist",
len(existing.PVEInstances)+len(existing.PBSInstances))
}
}
}
config := NodesConfig{
PVEInstances: pveInstances,
PBSInstances: pbsInstances,
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
if err := c.EnsureConfigDir(); err != nil {
return err
}
// Create TIMESTAMPED backup of existing file before overwriting (if it exists and has content)
// This ensures we keep multiple backups and can recover from disasters
if info, err := os.Stat(c.nodesFile); err == nil && info.Size() > 0 {
// Create timestamped backup
timestampedBackup := fmt.Sprintf("%s.backup-%s", c.nodesFile, time.Now().Format("20060102-150405"))
if backupData, err := os.ReadFile(c.nodesFile); err == nil {
if err := os.WriteFile(timestampedBackup, backupData, 0600); err != nil {
log.Warn().Err(err).Msg("Failed to create timestamped backup of nodes config")
} else {
log.Info().Str("backup", timestampedBackup).Msg("Created timestamped backup of nodes config")
}
}
// Also maintain a "latest" backup for quick recovery
latestBackup := c.nodesFile + ".backup"
if backupData, err := os.ReadFile(c.nodesFile); err == nil {
if err := os.WriteFile(latestBackup, backupData, 0600); err != nil {
log.Warn().Err(err).Msg("Failed to create latest backup of nodes config")
}
}
// Clean up old timestamped backups (keep last 10)
c.cleanupOldBackups(c.nodesFile + ".backup-*")
}
// Encrypt if crypto manager is available
if c.crypto != nil {
encrypted, err := c.crypto.Encrypt(data)
if err != nil {
return err
}
data = encrypted
}
// Write to temporary file first, then atomically rename
tempFile := c.nodesFile + ".tmp"
if err := os.WriteFile(tempFile, data, 0600); err != nil {
return err
}
// Atomic rename
if err := os.Rename(tempFile, c.nodesFile); err != nil {
os.Remove(tempFile) // Clean up temp file on error
return err
}
log.Info().Str("file", c.nodesFile).
Int("pve", len(pveInstances)).
Int("pbs", len(pbsInstances)).
Bool("encrypted", c.crypto != nil).
Msg("Nodes configuration saved")
return nil
}
// LoadNodesConfig loads nodes configuration from file (decrypts if encrypted)
func (c *ConfigPersistence) LoadNodesConfig() (*NodesConfig, error) {
c.mu.RLock()
defer c.mu.RUnlock()
data, err := os.ReadFile(c.nodesFile)
if err != nil {
if os.IsNotExist(err) {
// Return empty config if encrypted file doesn't exist
log.Info().Msg("No encrypted nodes configuration found, returning empty config")
return &NodesConfig{
PVEInstances: []PVEInstance{},
PBSInstances: []PBSInstance{},
}, nil
}
return nil, err
}
// Decrypt if crypto manager is available
if c.crypto != nil {
decrypted, err := c.crypto.Decrypt(data)
if err != nil {
// Decryption failed - file may be corrupted
log.Error().Err(err).Str("file", c.nodesFile).Msg("Failed to decrypt nodes config - file may be corrupted")
// Try to restore from backup
backupFile := c.nodesFile + ".backup"
if backupData, backupErr := os.ReadFile(backupFile); backupErr == nil {
log.Info().Str("backup", backupFile).Msg("Attempting to restore nodes config from backup")
if decryptedBackup, decryptErr := c.crypto.Decrypt(backupData); decryptErr == nil {
log.Info().Msg("Successfully decrypted backup file")
data = decryptedBackup
// Move corrupted file out of the way with timestamp
corruptedFile := fmt.Sprintf("%s.corrupted-%s", c.nodesFile, time.Now().Format("20060102-150405"))
if renameErr := os.Rename(c.nodesFile, corruptedFile); renameErr != nil {
log.Warn().Err(renameErr).Msg("Failed to rename corrupted file")
} else {
log.Warn().Str("corruptedFile", corruptedFile).Msg("Moved corrupted nodes config")
}
// Restore backup as current file
if writeErr := os.WriteFile(c.nodesFile, backupData, 0600); writeErr != nil {
log.Error().Err(writeErr).Msg("Failed to restore backup as current file")
} else {
log.Info().Msg("Successfully restored nodes config from backup")
}
} else {
log.Error().Err(decryptErr).Msg("Backup file is also corrupted or encrypted with different key")
// CRITICAL: Don't delete the corrupted file - leave it for manual recovery
// Create an empty config so startup can continue, but log prominently
log.Error().
Str("corruptedFile", c.nodesFile).
Str("backupFile", backupFile).
Msg("⚠️ CRITICAL: Both nodes.enc and backup are corrupted/unreadable. Encryption key may have been regenerated. Manual recovery required. Starting with empty config.")
// Move corrupted file with timestamp for forensics
corruptedFile := fmt.Sprintf("%s.corrupted-%s", c.nodesFile, time.Now().Format("20060102-150405"))
os.Rename(c.nodesFile, corruptedFile)
// Create empty but valid config so system can start
emptyConfig := NodesConfig{PVEInstances: []PVEInstance{}, PBSInstances: []PBSInstance{}}
emptyData, _ := json.Marshal(emptyConfig)
if c.crypto != nil {
emptyData, _ = c.crypto.Encrypt(emptyData)
}
os.WriteFile(c.nodesFile, emptyData, 0600)
return &emptyConfig, nil
}
} else {
log.Error().Err(backupErr).Msg("No backup file available for recovery")
// CRITICAL: Don't delete the corrupted file - leave it for manual recovery
log.Error().
Str("corruptedFile", c.nodesFile).
Msg("⚠️ CRITICAL: nodes.enc is corrupted and no backup exists. Encryption key may have been regenerated. Manual recovery required. Starting with empty config.")
// Move corrupted file with timestamp for forensics
corruptedFile := fmt.Sprintf("%s.corrupted-%s", c.nodesFile, time.Now().Format("20060102-150405"))
os.Rename(c.nodesFile, corruptedFile)
// Create empty but valid config so system can start
emptyConfig := NodesConfig{PVEInstances: []PVEInstance{}, PBSInstances: []PBSInstance{}}
emptyData, _ := json.Marshal(emptyConfig)
if c.crypto != nil {
emptyData, _ = c.crypto.Encrypt(emptyData)
}
os.WriteFile(c.nodesFile, emptyData, 0600)
return &emptyConfig, nil
}
} else {
data = decrypted
}
}
var config NodesConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
// Track if any migrations were applied
migrationApplied := false
// Fix for bug where TokenName was incorrectly set when using password auth
// If a PBS instance has both Password and TokenName, clear the TokenName
for i := range config.PBSInstances {
if config.PBSInstances[i].Password != "" && config.PBSInstances[i].TokenName != "" {
log.Info().
Str("instance", config.PBSInstances[i].Name).
Msg("Fixing PBS config: clearing TokenName since Password is set")
config.PBSInstances[i].TokenName = ""
config.PBSInstances[i].TokenValue = ""
}
// Fix for missing port in PBS host
host := config.PBSInstances[i].Host
if host != "" {
// Check if we need to add default port
protocolEnd := 0
if strings.HasPrefix(host, "https://") {
protocolEnd = 8
} else if strings.HasPrefix(host, "http://") {
protocolEnd = 7
} else if !strings.Contains(host, "://") {
// No protocol specified, add https and check for port
if !strings.Contains(host, ":") {
// No port specified, add protocol and default port
config.PBSInstances[i].Host = "https://" + host + ":8007"
log.Info().
Str("instance", config.PBSInstances[i].Name).
Str("oldHost", host).
Str("newHost", config.PBSInstances[i].Host).
Msg("Fixed PBS host by adding protocol and default port")
} else {
// Port specified, just add protocol
config.PBSInstances[i].Host = "https://" + host
log.Info().
Str("instance", config.PBSInstances[i].Name).
Str("oldHost", host).
Str("newHost", config.PBSInstances[i].Host).
Msg("Fixed PBS host by adding protocol")
}
} else if protocolEnd > 0 {
// Has protocol, check if port is missing
hostAfterProtocol := host[protocolEnd:]
if !strings.Contains(hostAfterProtocol, ":") {
// No port specified, add default PBS port
config.PBSInstances[i].Host = host + ":8007"
log.Info().
Str("instance", config.PBSInstances[i].Name).
Str("oldHost", host).
Str("newHost", config.PBSInstances[i].Host).
Msg("Fixed PBS host by adding default port 8007")
}
}
}
// Migration: Ensure MonitorBackups is enabled for PBS instances
// This fixes issue #411 where PBS backups weren't showing
if !config.PBSInstances[i].MonitorBackups {
log.Info().
Str("instance", config.PBSInstances[i].Name).
Msg("Enabling MonitorBackups for PBS instance (was disabled)")
config.PBSInstances[i].MonitorBackups = true
migrationApplied = true
}
}
// If any migrations were applied, save the updated configuration
if migrationApplied {
log.Info().Msg("Migrations applied, saving updated configuration")
// Need to unlock before saving to avoid deadlock
c.mu.RUnlock()
if err := c.SaveNodesConfig(config.PVEInstances, config.PBSInstances); err != nil {
log.Error().Err(err).Msg("Failed to save configuration after migration")
}
c.mu.RLock()
}
log.Info().Str("file", c.nodesFile).
Int("pve", len(config.PVEInstances)).
Int("pbs", len(config.PBSInstances)).
Bool("encrypted", c.crypto != nil).
Msg("Nodes configuration loaded")
return &config, nil
}
// SaveSystemSettings saves system settings to file
func (c *ConfigPersistence) SaveSystemSettings(settings SystemSettings) error {
c.mu.Lock()
defer c.mu.Unlock()
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
if err := c.EnsureConfigDir(); err != nil {
return err
}
if err := os.WriteFile(c.systemFile, data, 0600); err != nil {
return err
}
// Also update the .env file if it exists
envFile := filepath.Join(c.configDir, ".env")
if err := c.updateEnvFile(envFile, settings); err != nil {
log.Warn().Err(err).Msg("Failed to update .env file")
// Don't fail the operation if .env update fails
}
log.Info().Str("file", c.systemFile).Msg("System settings saved")
return nil
}
// SaveOIDCConfig stores OIDC settings, encrypting them when a crypto manager is available.
func (c *ConfigPersistence) SaveOIDCConfig(settings OIDCConfig) error {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.EnsureConfigDir(); err != nil {
return err
}
// Do not persist runtime-only flags.
settings.EnvOverrides = nil
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}
if c.crypto != nil {
encrypted, err := c.crypto.Encrypt(data)
if err != nil {
return err
}
data = encrypted
}
if err := os.WriteFile(c.oidcFile, data, 0600); err != nil {
return err
}
log.Info().Str("file", c.oidcFile).Msg("OIDC configuration saved")
return nil
}
// LoadOIDCConfig retrieves the persisted OIDC settings. It returns nil when no configuration exists yet.
func (c *ConfigPersistence) LoadOIDCConfig() (*OIDCConfig, error) {
c.mu.RLock()
defer c.mu.RUnlock()
data, err := os.ReadFile(c.oidcFile)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
if c.crypto != nil {
decrypted, err := c.crypto.Decrypt(data)
if err != nil {
return nil, err
}
data = decrypted
}
var settings OIDCConfig
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
log.Info().Str("file", c.oidcFile).Msg("OIDC configuration loaded")
return &settings, nil
}
// LoadSystemSettings loads system settings from file
func (c *ConfigPersistence) LoadSystemSettings() (*SystemSettings, error) {
c.mu.RLock()
defer c.mu.RUnlock()
data, err := os.ReadFile(c.systemFile)
if err != nil {
if os.IsNotExist(err) {
// Return nil if file doesn't exist - let env vars take precedence
return nil, nil
}
return nil, err
}
var settings SystemSettings
if err := json.Unmarshal(data, &settings); err != nil {
return nil, err
}
log.Info().Str("file", c.systemFile).Msg("System settings loaded")
return &settings, nil
}
// updateEnvFile updates the .env file with new system settings
func (c *ConfigPersistence) updateEnvFile(envFile string, settings SystemSettings) error {
// Check if .env file exists
if _, err := os.Stat(envFile); os.IsNotExist(err) {
// File doesn't exist, nothing to update
return nil
}
// Read the existing .env file
file, err := os.Open(envFile)
if err != nil {
return err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// Skip POLLING_INTERVAL lines - deprecated
if strings.HasPrefix(line, "POLLING_INTERVAL=") {
// Skip this line, polling interval is now hardcoded
continue
} else if strings.HasPrefix(line, "UPDATE_CHANNEL=") && settings.UpdateChannel != "" {
lines = append(lines, fmt.Sprintf("UPDATE_CHANNEL=%s", settings.UpdateChannel))
} else if strings.HasPrefix(line, "AUTO_UPDATE_ENABLED=") {
// Always update AUTO_UPDATE_ENABLED when the line exists
lines = append(lines, fmt.Sprintf("AUTO_UPDATE_ENABLED=%t", settings.AutoUpdateEnabled))
} else if strings.HasPrefix(line, "AUTO_UPDATE_CHECK_INTERVAL=") && settings.AutoUpdateCheckInterval > 0 {
lines = append(lines, fmt.Sprintf("AUTO_UPDATE_CHECK_INTERVAL=%d", settings.AutoUpdateCheckInterval))
} else {
lines = append(lines, line)
}
}
if err := scanner.Err(); err != nil {
return err
}
// Note: POLLING_INTERVAL is deprecated and no longer written
// Write the updated content back atomically
content := strings.Join(lines, "\n")
if len(lines) > 0 && !strings.HasSuffix(content, "\n") {
content += "\n"
}
// Write to temp file first
tempFile := envFile + ".tmp"
if err := os.WriteFile(tempFile, []byte(content), 0644); err != nil {
return err
}
// Atomic rename
return os.Rename(tempFile, envFile)
}
// cleanupOldBackups removes old backup files, keeping only the most recent N backups
func (c *ConfigPersistence) cleanupOldBackups(pattern string) {
// Use filepath.Glob to find all backup files matching the pattern
matches, err := filepath.Glob(pattern)
if err != nil {
log.Warn().Err(err).Str("pattern", pattern).Msg("Failed to find backup files for cleanup")
return
}
// Keep only the last 10 backups
const maxBackups = 10
if len(matches) <= maxBackups {
return
}
// Sort by modification time (oldest first)
type fileInfo struct {
path string
modTime time.Time
}
var files []fileInfo
for _, match := range matches {
info, err := os.Stat(match)
if err != nil {
continue
}
files = append(files, fileInfo{path: match, modTime: info.ModTime()})
}
// Sort oldest first
for i := 0; i < len(files)-1; i++ {
for j := i + 1; j < len(files); j++ {
if files[i].modTime.After(files[j].modTime) {
files[i], files[j] = files[j], files[i]
}
}
}
// Delete oldest backups (keep last 10)
toDelete := len(files) - maxBackups
for i := 0; i < toDelete; i++ {
if err := os.Remove(files[i].path); err != nil {
log.Warn().Err(err).Str("file", files[i].path).Msg("Failed to delete old backup")
} else {
log.Debug().Str("file", files[i].path).Msg("Deleted old backup")
}
}
}