Pulse/internal/config/export.go
Pulse Monitor 1109276fd3 feat: add encrypted config export/import for automation
- Added secure config export/import with passphrase-based encryption
- CLI commands: pulse config export/import with AES-256-GCM encryption
- Auto-import on Docker startup via PULSE_INIT_CONFIG_FILE/DATA env vars
- API endpoints /api/config/export and /api/config/import (require API_TOKEN)
- Configs remain encrypted throughout export/import process
- Perfect for GitOps, CI/CD, and infrastructure as code workflows

This allows users to configure Pulse once via UI, export the encrypted
config, and deploy it automatically to multiple instances without
manual reconfiguration.

Addresses #249 - Config management for automation enthusiasts
2025-08-05 21:45:25 +00:00

225 lines
No EOL
5.9 KiB
Go

package config
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"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"`
System SystemSettings `json:"system"`
}
// 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")
}
c.mu.RLock()
defer c.mu.RUnlock()
// Load all configurations
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)
}
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)
}
// Create export data
exportData := ExportData{
Version: "4.0",
ExportedAt: time.Now(),
Nodes: *nodes,
Alerts: *alertConfig,
Email: *emailConfig,
Webhooks: webhooks,
System: *systemSettings,
}
// 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)
if exportData.Version != "4.0" {
// Log warning but continue - future versions might be compatible
fmt.Printf("Warning: Config was exported from version %s, current version is 4.0\n", exportData.Version)
}
// Import all configurations
if err := c.SaveNodesConfig(exportData.Nodes.PVEInstances, exportData.Nodes.PBSInstances); 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.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)
}
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
}