Pulse/internal/config/export.go
rcourtman 00793c7688 fix: resolve ExportConfig deadlock and update apprise test
ExportConfig held a read lock while calling LoadNodesConfig, which
could trigger a migration requiring a write lock - causing a deadlock.
Removed the redundant outer lock since each Load function manages its
own locking.

Also updated TestAppriseConfigPersistence to match the security fix
that hardcodes CLIPath to "apprise" to prevent RCE.
2026-02-03 23:35:36 +00:00

329 lines
9.1 KiB
Go

package config
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/pbkdf2"
)
// ExportData contains all configuration data for export
type ExportData struct {
Version string `json:"version"`
ExportedAt time.Time `json:"exportedAt"`
Nodes NodesConfig `json:"nodes"`
Alerts alerts.AlertConfig `json:"alerts"`
Email notifications.EmailConfig `json:"email"`
Webhooks []notifications.WebhookConfig `json:"webhooks"`
Apprise notifications.AppriseConfig `json:"apprise"`
System SystemSettings `json:"system"`
GuestMetadata map[string]*GuestMetadata `json:"guestMetadata,omitempty"`
OIDC *OIDCConfig `json:"oidc,omitempty"`
APITokens []APITokenRecord `json:"apiTokens,omitempty"`
}
// ExportConfig exports all configuration with passphrase-based encryption
func (c *ConfigPersistence) ExportConfig(passphrase string) (string, error) {
if passphrase == "" {
return "", fmt.Errorf("passphrase is required for export")
}
// Each Load function handles its own locking. Do NOT hold an outer lock
// here: LoadNodesConfig may need to acquire a write lock to persist
// migrations, which would deadlock against an outer read lock.
nodes, err := c.LoadNodesConfig()
if err != nil {
return "", fmt.Errorf("failed to load nodes config: %w", err)
}
alertConfig, err := c.LoadAlertConfig()
if err != nil {
return "", fmt.Errorf("failed to load alert config: %w", err)
}
emailConfig, err := c.LoadEmailConfig()
if err != nil {
return "", fmt.Errorf("failed to load email config: %w", err)
}
appriseConfig, err := c.LoadAppriseConfig()
if err != nil {
return "", fmt.Errorf("failed to load Apprise config: %w", err)
}
webhooks, err := c.LoadWebhooks()
if err != nil {
return "", fmt.Errorf("failed to load webhooks: %w", err)
}
systemSettings, err := c.LoadSystemSettings()
if err != nil {
return "", fmt.Errorf("failed to load system settings: %w", err)
}
if systemSettings == nil {
systemSettings = DefaultSystemSettings()
}
oidcConfig, err := c.LoadOIDCConfig()
if err != nil {
return "", fmt.Errorf("failed to load oidc configuration: %w", err)
}
apiTokens, err := c.LoadAPITokens()
if err != nil {
return "", fmt.Errorf("failed to load api tokens: %w", err)
}
if apiTokens == nil {
apiTokens = []APITokenRecord{}
}
// Load guest metadata (stored in data directory)
// Use PULSE_DATA_DIR if set, otherwise use /etc/pulse for backwards compatibility
dataPath := os.Getenv("PULSE_DATA_DIR")
if dataPath == "" {
dataPath = "/etc/pulse"
}
guestMetadataStore := NewGuestMetadataStore(dataPath, c.fs)
guestMetadata := guestMetadataStore.GetAll()
// Create export data
exportData := ExportData{
Version: "4.1",
ExportedAt: time.Now(),
Nodes: *nodes,
Alerts: *alertConfig,
Email: *emailConfig,
Webhooks: webhooks,
Apprise: *appriseConfig,
System: *systemSettings,
GuestMetadata: guestMetadata,
OIDC: oidcConfig,
APITokens: apiTokens,
}
// Marshal to JSON
jsonData, err := json.Marshal(exportData)
if err != nil {
return "", fmt.Errorf("failed to marshal export data: %w", err)
}
// Encrypt with passphrase
encrypted, err := encryptWithPassphrase(jsonData, passphrase)
if err != nil {
return "", fmt.Errorf("failed to encrypt export data: %w", err)
}
// Return base64 encoded
return base64.StdEncoding.EncodeToString(encrypted), nil
}
// ImportConfig imports configuration from encrypted export
func (c *ConfigPersistence) ImportConfig(encryptedData string, passphrase string) error {
if passphrase == "" {
return fmt.Errorf("passphrase is required for import")
}
// Decode from base64
encrypted, err := base64.StdEncoding.DecodeString(encryptedData)
if err != nil {
return fmt.Errorf("failed to decode import data: %w", err)
}
// Decrypt with passphrase
decrypted, err := decryptWithPassphrase(encrypted, passphrase)
if err != nil {
return fmt.Errorf("failed to decrypt import data: %w", err)
}
// Unmarshal JSON
var exportData ExportData
if err := json.Unmarshal(decrypted, &exportData); err != nil {
return fmt.Errorf("failed to unmarshal import data: %w", err)
}
// Check version compatibility (warn but don't fail)
switch exportData.Version {
case "4.1", "":
// current version, nothing to do
case "4.0":
log.Info().Msg("Config was exported from version 4.0. API tokens were not included in that format.")
default:
log.Warn().Str("version", exportData.Version).Msg("Config was exported from unsupported version. Proceeding with best effort.")
}
tx, err := newImportTransaction(c.configDir)
if err != nil {
return fmt.Errorf("failed to start import transaction: %w", err)
}
defer tx.Cleanup()
c.beginTransaction(tx)
defer c.endTransaction(tx)
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
// Import all configurations
if err := c.SaveNodesConfig(exportData.Nodes.PVEInstances, exportData.Nodes.PBSInstances, exportData.Nodes.PMGInstances); err != nil {
return fmt.Errorf("failed to import nodes config: %w", err)
}
if err := c.SaveAlertConfig(exportData.Alerts); err != nil {
return fmt.Errorf("failed to import alert config: %w", err)
}
if err := c.SaveEmailConfig(exportData.Email); err != nil {
return fmt.Errorf("failed to import email config: %w", err)
}
if err := c.SaveAppriseConfig(exportData.Apprise); err != nil {
return fmt.Errorf("failed to import Apprise config: %w", err)
}
if err := c.SaveWebhooks(exportData.Webhooks); err != nil {
return fmt.Errorf("failed to import webhooks: %w", err)
}
if err := c.SaveSystemSettings(exportData.System); err != nil {
return fmt.Errorf("failed to import system settings: %w", err)
}
// Import API tokens for newer export formats
if exportData.Version == "4.1" {
if exportData.APITokens == nil {
exportData.APITokens = []APITokenRecord{}
}
if err := c.SaveAPITokens(exportData.APITokens); err != nil {
return fmt.Errorf("failed to import api tokens: %w", err)
}
}
// Import OIDC configuration
if exportData.OIDC != nil {
if err := c.SaveOIDCConfig(*exportData.OIDC); err != nil {
return fmt.Errorf("failed to import oidc configuration: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit import transaction: %w", err)
}
committed = true
if exportData.OIDC == nil {
// Remove existing OIDC config if backup did not include one
if err := c.fs.Remove(c.oidcFile); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove existing oidc configuration: %w", err)
}
}
// Import guest metadata if present
// Use PULSE_DATA_DIR if set, otherwise use /etc/pulse for backwards compatibility
dataPath := os.Getenv("PULSE_DATA_DIR")
if dataPath == "" {
dataPath = "/etc/pulse"
}
guestMetadataStore := NewGuestMetadataStore(dataPath, c.fs)
if err := guestMetadataStore.ReplaceAll(exportData.GuestMetadata); err != nil {
log.Warn().Err(err).Msg("Failed to import guest metadata")
}
return nil
}
// encryptWithPassphrase encrypts data using a passphrase-derived key
func encryptWithPassphrase(plaintext []byte, passphrase string) ([]byte, error) {
// Generate salt
salt := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, err
}
// Derive key from passphrase using PBKDF2
key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New)
// Create cipher
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Use GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Generate nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
// Encrypt
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
// Prepend salt to ciphertext
result := make([]byte, len(salt)+len(ciphertext))
copy(result, salt)
copy(result[len(salt):], ciphertext)
return result, nil
}
// decryptWithPassphrase decrypts data using a passphrase-derived key
func decryptWithPassphrase(ciphertext []byte, passphrase string) ([]byte, error) {
if len(ciphertext) < 32 {
return nil, fmt.Errorf("ciphertext too short")
}
// Extract salt
salt := ciphertext[:32]
ciphertext = ciphertext[32:]
// Derive key from passphrase
key := pbkdf2.Key([]byte(passphrase), salt, 100000, 32, sha256.New)
// Create cipher
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Use GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Extract nonce
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
// Decrypt
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}