Pulse/internal/config/config.go
Pulse Monitor 9f6a3d1b5f fix: multiple UI and configuration issues (addresses #295, #296)
Frontend fixes:
- Fixed VM thresholds incorrectly showing as 'CT' in alerts UI (issue #295)
- Fixed threshold slider not reaching 0% and 100% edges (issue #295)
- Fixed PBS form auto-filling with PVE settings (issue #296)
- Fixed email config property mapping (server/port instead of smtpHost/smtpPort)

Backend fixes:
- Updated EmailConfig struct to use 'server' and 'port' JSON tags for consistency
- Added 'provider' and 'startTLS' fields to EmailConfig
- Fixed PORT env var to correctly set FrontendPort instead of BackendPort
- Changed default log level from Debug to Info

All changes tested and verified working correctly.
2025-08-11 15:29:53 +00:00

394 lines
No EOL
14 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// Config holds all application configuration
// NOTE: The envconfig tags are legacy and not used - configuration is loaded from encrypted JSON files
type Config struct {
// Server settings
BackendHost string `envconfig:"BACKEND_HOST" default:"0.0.0.0"`
BackendPort int `envconfig:"BACKEND_PORT" default:"3000"`
FrontendHost string `envconfig:"FRONTEND_HOST" default:"0.0.0.0"`
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
ConfigPath string `envconfig:"CONFIG_PATH" default:"/etc/pulse"`
DataPath string `envconfig:"DATA_PATH" default:"/var/lib/pulse"`
// Proxmox VE connections
PVEInstances []PVEInstance
// Proxmox Backup Server connections
PBSInstances []PBSInstance
// Monitoring settings
PollingInterval time.Duration `envconfig:"POLLING_INTERVAL"` // Loaded from system.json
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"10s"`
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"`
// Logging settings
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
LogFile string `envconfig:"LOG_FILE" default:""`
LogMaxSize int `envconfig:"LOG_MAX_SIZE" default:"100"` // MB
LogMaxAge int `envconfig:"LOG_MAX_AGE" default:"30"` // days
LogCompress bool `envconfig:"LOG_COMPRESS" default:"true"`
// Security settings
APIToken string `envconfig:"API_TOKEN"`
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"`
// Update settings
UpdateChannel string `envconfig:"UPDATE_CHANNEL" default:"stable"`
AutoUpdateEnabled bool `envconfig:"AUTO_UPDATE_ENABLED" default:"false"`
AutoUpdateCheckInterval time.Duration `envconfig:"AUTO_UPDATE_CHECK_INTERVAL" default:"24h"`
AutoUpdateTime string `envconfig:"AUTO_UPDATE_TIME" default:"03:00"`
// Discovery settings
DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"`
// Deprecated - for backward compatibility
Port int `envconfig:"PORT"` // Maps to BackendPort
Debug bool `envconfig:"DEBUG" default:"false"`
}
// PVEInstance represents a Proxmox VE connection
type PVEInstance struct {
Name string
Host string // Primary endpoint (user-provided)
User string
Password string
TokenName string
TokenValue string
Fingerprint string
VerifySSL bool
MonitorVMs bool
MonitorContainers bool
MonitorStorage bool
MonitorBackups bool
// Cluster support
IsCluster bool // True if this is a cluster
ClusterName string // Cluster name if applicable
ClusterEndpoints []ClusterEndpoint // All discovered cluster nodes
}
// ClusterEndpoint represents a single node in a cluster
type ClusterEndpoint struct {
NodeID string // Node ID in cluster
NodeName string // Node name
Host string // Full URL (e.g., https://node1.lan:8006)
IP string // IP address
Online bool // Current online status
LastSeen time.Time // Last successful connection
}
// PBSInstance represents a Proxmox Backup Server connection
type PBSInstance struct {
Name string
Host string
User string
Password string
TokenName string
TokenValue string
Fingerprint string
VerifySSL bool
MonitorBackups bool
MonitorDatastores bool
MonitorSyncJobs bool
MonitorVerifyJobs bool
MonitorPruneJobs bool
MonitorGarbageJobs bool
}
// Global persistence instance for saving
var globalPersistence *ConfigPersistence
// Load reads configuration from encrypted persistence files
func Load() (*Config, error) {
// Get data directory from environment
dataDir := "/etc/pulse"
if dir := os.Getenv("PULSE_DATA_DIR"); dir != "" {
dataDir = dir
}
// Load .env file if it exists (for deployment overrides)
envFile := filepath.Join(dataDir, ".env")
if _, err := os.Stat(envFile); err == nil {
if err := godotenv.Load(envFile); err != nil {
log.Warn().Err(err).Str("file", envFile).Msg("Failed to load .env file")
} else {
log.Info().Str("file", envFile).Msg("Loaded .env file for deployment overrides")
}
}
// Also try loading from current directory for development
if err := godotenv.Load(); err == nil {
log.Info().Msg("Loaded configuration from .env in current directory")
}
// Initialize config with defaults
cfg := &Config{
BackendHost: "0.0.0.0",
BackendPort: 3000,
FrontendHost: "0.0.0.0",
FrontendPort: 7655,
ConfigPath: dataDir,
DataPath: dataDir,
ConcurrentPolling: true,
ConnectionTimeout: 10 * time.Second,
MetricsRetentionDays: 7,
BackupPollingCycles: 10,
WebhookBatchDelay: 10 * time.Second,
LogLevel: "info",
LogMaxSize: 100,
LogMaxAge: 30,
LogCompress: true,
AllowedOrigins: "*",
IframeEmbeddingAllow: "SAMEORIGIN",
PollingInterval: 3 * time.Second,
DiscoverySubnet: "auto",
}
// Initialize persistence
persistence := NewConfigPersistence(dataDir)
hasSystemConfig := false
if persistence != nil {
// Store global persistence for saving
globalPersistence = persistence
// Load nodes configuration
if nodesConfig, err := persistence.LoadNodesConfig(); err == nil && nodesConfig != nil {
cfg.PVEInstances = nodesConfig.PVEInstances
cfg.PBSInstances = nodesConfig.PBSInstances
log.Info().
Int("pve", len(cfg.PVEInstances)).
Int("pbs", len(cfg.PBSInstances)).
Msg("Loaded nodes configuration")
}
// Load system configuration
if systemSettings, err := persistence.LoadSystemSettings(); err == nil && systemSettings != nil {
hasSystemConfig = true
if systemSettings.PollingInterval > 0 {
cfg.PollingInterval = time.Duration(systemSettings.PollingInterval) * time.Second
}
if systemSettings.UpdateChannel != "" {
cfg.UpdateChannel = systemSettings.UpdateChannel
}
cfg.AutoUpdateEnabled = systemSettings.AutoUpdateEnabled
if systemSettings.AutoUpdateCheckInterval > 0 {
cfg.AutoUpdateCheckInterval = time.Duration(systemSettings.AutoUpdateCheckInterval) * time.Hour
}
if systemSettings.AutoUpdateTime != "" {
cfg.AutoUpdateTime = systemSettings.AutoUpdateTime
}
if systemSettings.AllowedOrigins != "" {
cfg.AllowedOrigins = systemSettings.AllowedOrigins
}
if systemSettings.ConnectionTimeout > 0 {
cfg.ConnectionTimeout = time.Duration(systemSettings.ConnectionTimeout) * time.Second
}
log.Info().
Dur("interval", cfg.PollingInterval).
Str("updateChannel", cfg.UpdateChannel).
Msg("Loaded system configuration")
}
}
// Limited environment variable support
// NOTE: Node configuration is NOT done via env vars - use the web UI instead
if port := os.Getenv("PORT"); port != "" {
if p, err := strconv.Atoi(port); err == nil {
cfg.FrontendPort = p // Fixed: PORT should set FrontendPort (the actual listening port)
log.Info().Int("port", p).Msg("Overriding frontend port from PORT env var")
}
}
if apiToken := os.Getenv("API_TOKEN"); apiToken != "" {
cfg.APIToken = apiToken
log.Info().Msg("Overriding API token from env var")
}
if updateChannel := os.Getenv("UPDATE_CHANNEL"); updateChannel != "" {
cfg.UpdateChannel = updateChannel
log.Info().Str("channel", updateChannel).Msg("Overriding update channel from env var")
} else if updateChannel := os.Getenv("PULSE_UPDATE_CHANNEL"); updateChannel != "" {
cfg.UpdateChannel = updateChannel
log.Info().Str("channel", updateChannel).Msg("Overriding update channel from PULSE_ env var")
}
// Auto-update settings from env vars
if autoUpdateEnabled := os.Getenv("AUTO_UPDATE_ENABLED"); autoUpdateEnabled != "" {
cfg.AutoUpdateEnabled = autoUpdateEnabled == "true" || autoUpdateEnabled == "1"
log.Info().Bool("enabled", cfg.AutoUpdateEnabled).Msg("Overriding auto-update enabled from env var")
}
if interval := os.Getenv("AUTO_UPDATE_CHECK_INTERVAL"); interval != "" {
if i, err := strconv.Atoi(interval); err == nil && i > 0 {
cfg.AutoUpdateCheckInterval = time.Duration(i) * time.Hour
log.Info().Int("hours", i).Msg("Overriding auto-update check interval from env var")
}
}
if updateTime := os.Getenv("AUTO_UPDATE_TIME"); updateTime != "" {
cfg.AutoUpdateTime = updateTime
log.Info().Str("time", updateTime).Msg("Overriding auto-update time from env var")
}
// Other settings from env vars - only use if not already set from system.json
if pollingInterval := os.Getenv("POLLING_INTERVAL"); pollingInterval != "" {
// Only use env var if system.json doesn't exist (for backwards compatibility)
if !hasSystemConfig {
if i, err := strconv.Atoi(pollingInterval); err == nil && i > 0 {
cfg.PollingInterval = time.Duration(i) * time.Second
log.Info().Int("seconds", i).Msg("Using polling interval from env var (no system.json exists)")
}
} else {
log.Debug().Str("env_value", pollingInterval).Msg("Ignoring POLLING_INTERVAL env var - using system.json value")
}
}
if connectionTimeout := os.Getenv("CONNECTION_TIMEOUT"); connectionTimeout != "" {
if i, err := strconv.Atoi(connectionTimeout); err == nil && i > 0 {
cfg.ConnectionTimeout = time.Duration(i) * time.Second
log.Info().Int("seconds", i).Msg("Overriding connection timeout from env var")
}
}
if allowedOrigins := os.Getenv("ALLOWED_ORIGINS"); allowedOrigins != "" {
cfg.AllowedOrigins = allowedOrigins
log.Info().Str("origins", allowedOrigins).Msg("Overriding allowed origins from env var")
}
if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" {
cfg.LogLevel = logLevel
log.Info().Str("level", logLevel).Msg("Overriding log level from env var")
}
// Discovery settings from env vars
if discoverySubnet := os.Getenv("DISCOVERY_SUBNET"); discoverySubnet != "" {
cfg.DiscoverySubnet = discoverySubnet
log.Info().Str("subnet", discoverySubnet).Msg("Overriding discovery subnet from env var")
}
// Set log level
switch cfg.LogLevel {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
zerolog.SetGlobalLevel(zerolog.InfoLevel) // Default to info level
}
// Validate configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return cfg, nil
}
// SaveConfig saves the configuration back to encrypted files
func SaveConfig(cfg *Config) error {
if globalPersistence == nil {
return fmt.Errorf("config persistence not initialized")
}
// Save nodes configuration
if err := globalPersistence.SaveNodesConfig(cfg.PVEInstances, cfg.PBSInstances); err != nil {
return fmt.Errorf("failed to save nodes config: %w", err)
}
// Save system configuration
systemSettings := SystemSettings{
PollingInterval: int(cfg.PollingInterval.Seconds()),
UpdateChannel: cfg.UpdateChannel,
AutoUpdateEnabled: cfg.AutoUpdateEnabled,
AutoUpdateCheckInterval: int(cfg.AutoUpdateCheckInterval.Hours()),
AutoUpdateTime: cfg.AutoUpdateTime,
AllowedOrigins: cfg.AllowedOrigins,
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
}
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
return fmt.Errorf("failed to save system config: %w", err)
}
return nil
}
// UpdatePollingInterval updates just the polling interval
func UpdatePollingInterval(interval int) error {
if globalPersistence == nil {
return fmt.Errorf("config persistence not initialized")
}
systemSettings := SystemSettings{
PollingInterval: interval,
}
return globalPersistence.SaveSystemSettings(systemSettings)
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Validate server settings
if c.BackendPort <= 0 || c.BackendPort > 65535 {
return fmt.Errorf("invalid backend port: %d", c.BackendPort)
}
if c.FrontendPort <= 0 || c.FrontendPort > 65535 {
return fmt.Errorf("invalid frontend port: %d", c.FrontendPort)
}
// Validate monitoring settings
if c.PollingInterval < time.Second {
return fmt.Errorf("polling interval must be at least 1 second")
}
if c.ConnectionTimeout < time.Second {
return fmt.Errorf("connection timeout must be at least 1 second")
}
// Validate PVE instances
for i, pve := range c.PVEInstances {
if pve.Host == "" {
return fmt.Errorf("PVE instance %d: host is required", i+1)
}
if !strings.HasPrefix(pve.Host, "http://") && !strings.HasPrefix(pve.Host, "https://") {
return fmt.Errorf("PVE instance %d: host must start with http:// or https://", i+1)
}
// Must have either password or token
if pve.Password == "" && (pve.TokenName == "" || pve.TokenValue == "") {
return fmt.Errorf("PVE instance %d: either password or token authentication is required", i+1)
}
}
// Validate and auto-fix PBS instances
validPBS := []PBSInstance{}
for i, pbs := range c.PBSInstances {
if pbs.Host == "" {
log.Warn().Int("instance", i+1).Msg("PBS instance missing host, skipping")
continue
}
// Auto-fix missing protocol
if !strings.HasPrefix(pbs.Host, "http://") && !strings.HasPrefix(pbs.Host, "https://") {
pbs.Host = "https://" + pbs.Host
log.Info().Str("host", pbs.Host).Msg("PBS host auto-corrected to include https://")
}
// Check authentication
if pbs.Password == "" && (pbs.TokenName == "" || pbs.TokenValue == "") {
log.Warn().Int("instance", i+1).Str("host", pbs.Host).Msg("PBS instance missing authentication, skipping")
continue
}
validPBS = append(validPBS, pbs)
}
c.PBSInstances = validPBS
return nil
}