mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-12 15:28:12 +00:00
30fa3fd810
Add comprehensive PMG monitoring with mail statistics, queue depth tracking, spam distribution analysis, and quarantine monitoring. Includes full discovery support and UI consistency improvements across all Proxmox products. Backend: - Add pkg/pmg package with complete API client for PMG operations - Implement mail statistics collection (inbound/outbound, spam, virus, bounces) - Add queue depth monitoring (active, deferred, hold, incoming queues) - Support spam score distribution and quarantine totals - Add PMG-specific discovery logic to differentiate from PVE on port 8006 - Extend mock data generator with realistic PMG instances and metrics - Add PMG node configuration support in config system Frontend: - Create MailGateway.tsx component with detailed PMG dashboard - Display mail flow statistics with time-series charts - Show queue depth with color-coded warnings (>50 messages or >30min age) - Add spam distribution histogram and quarantine status - Support cluster node status with individual queue monitoring - Add PMG to network discovery with purple branding and mail icon - Implement conditional navigation (hide PMG tab when no instances configured) - Standardize discovery UI controls across PVE/PBS/PMG settings pages API: - Add /api/config/pmg endpoints for node configuration - Support PMG-specific monitoring toggles (mail stats, queues, quarantine) - Extend system settings with PMG configuration options Discovery: - Detect PMG vs PVE on shared port 8006 using /api2/json/statistics/mail endpoint - Return 'pmg' type for mail gateway servers in discovery results - Update DiscoveryModal to display PMG servers with appropriate styling This completes ecosystem monitoring support for all three Proxmox products: Proxmox VE, Proxmox Backup Server, and Proxmox Mail Gateway.
271 lines
7.5 KiB
Go
271 lines
7.5 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"
|
|
"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"`
|
|
GuestMetadata map[string]*GuestMetadata `json:"guestMetadata,omitempty"`
|
|
OIDC *OIDCConfig `json:"oidc,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")
|
|
}
|
|
|
|
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)
|
|
}
|
|
if systemSettings == nil {
|
|
systemSettings = &SystemSettings{}
|
|
}
|
|
|
|
oidcConfig, err := c.LoadOIDCConfig()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to load oidc configuration: %w", err)
|
|
}
|
|
|
|
// 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)
|
|
guestMetadata := guestMetadataStore.GetAll()
|
|
|
|
// Create export data
|
|
exportData := ExportData{
|
|
Version: "4.0",
|
|
ExportedAt: time.Now(),
|
|
Nodes: *nodes,
|
|
Alerts: *alertConfig,
|
|
Email: *emailConfig,
|
|
Webhooks: webhooks,
|
|
System: *systemSettings,
|
|
GuestMetadata: guestMetadata,
|
|
OIDC: oidcConfig,
|
|
}
|
|
|
|
// 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, 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.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 OIDC configuration
|
|
if exportData.OIDC != nil {
|
|
if err := c.SaveOIDCConfig(*exportData.OIDC); err != nil {
|
|
return fmt.Errorf("failed to import oidc configuration: %w", err)
|
|
}
|
|
} else {
|
|
// Remove existing OIDC config if backup did not include one
|
|
if err := os.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)
|
|
if err := guestMetadataStore.ReplaceAll(exportData.GuestMetadata); err != nil {
|
|
fmt.Printf("Warning: Failed to import guest metadata: %v\n", 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
|
|
}
|