Pulse/internal/config/config.go
rcourtman 5c54685f04 Add API token scopes and standalone host agent
Introduces granular permission scopes for API tokens (docker:report, docker:manage, host-agent:report, monitoring:read/write, settings:read/write) allowing tokens to be restricted to minimum required access. Legacy tokens default to full access until scopes are explicitly configured.

Adds standalone host agent for monitoring Linux, macOS, and Windows servers outside Proxmox/Docker estates. New Servers workspace in UI displays uptime, OS metadata, and capacity metrics from enrolled agents.

Includes comprehensive token management UI overhaul with scope presets, inline editing, and visual scope indicators.
2025-10-23 11:40:31 +00:00

1310 lines
50 KiB
Go

// Package config manages Pulse configuration from multiple sources.
//
// Configuration File Separation:
// - .env: Authentication credentials ONLY (PULSE_AUTH_USER, PULSE_AUTH_PASS, API_TOKEN/API_TOKENS)
// - system.json: Application settings (polling interval, timeouts, update settings, etc.)
// - nodes.enc: Encrypted node credentials (PVE/PBS passwords and tokens)
//
// This separation ensures security, clarity, and proper access control.
// See docs/CONFIGURATION.md for detailed documentation.
package config
import (
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/joho/godotenv"
"github.com/pkg/errors"
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
"github.com/rs/zerolog/log"
)
// IsPasswordHashed checks if a string looks like a bcrypt hash
func IsPasswordHashed(password string) bool {
// Bcrypt hashes start with $2a$, $2b$, or $2y$ and are 60 characters long
// We check for >= 55 to catch truncated hashes and warn users
if !strings.HasPrefix(password, "$2") {
return false
}
length := len(password)
if length == 60 {
return true // Perfect bcrypt hash
}
// Warn about truncated or invalid hashes
if length >= 55 && length < 60 {
log.Error().
Int("length", length).
Str("hash_start", password[:20]+"...").
Msg("Bcrypt hash appears truncated! Should be 60 characters. Password will be treated as plaintext.")
return false // Treat as plaintext to force user to fix it
}
return false
}
// 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"`
PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://192.168.1.100:7655)
// Proxmox VE connections
PVEInstances []PVEInstance
// Proxmox Backup Server connections
PBSInstances []PBSInstance
// Proxmox Mail Gateway connections
PMGInstances []PMGInstance
// Monitoring settings
// Note: PVE polling is hardcoded to 10s since Proxmox cluster/resources endpoint only updates every 10s
PBSPollingInterval time.Duration `envconfig:"PBS_POLLING_INTERVAL"` // PBS polling interval (60s default)
PMGPollingInterval time.Duration `envconfig:"PMG_POLLING_INTERVAL"` // PMG polling interval (60s default)
ConcurrentPolling bool `envconfig:"CONCURRENT_POLLING" default:"true"`
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"45s"` // Increased for slow storage operations
MetricsRetentionDays int `envconfig:"METRICS_RETENTION_DAYS" default:"7"`
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
BackupPollingInterval time.Duration `envconfig:"BACKUP_POLLING_INTERVAL"`
EnableBackupPolling bool `envconfig:"ENABLE_BACKUP_POLLING" default:"true"`
WebhookBatchDelay time.Duration `envconfig:"WEBHOOK_BATCH_DELAY" default:"10s"`
AdaptivePollingEnabled bool `envconfig:"ADAPTIVE_POLLING_ENABLED" default:"false"`
AdaptivePollingBaseInterval time.Duration `envconfig:"ADAPTIVE_POLLING_BASE_INTERVAL" default:"10s"`
AdaptivePollingMinInterval time.Duration `envconfig:"ADAPTIVE_POLLING_MIN_INTERVAL" default:"5s"`
AdaptivePollingMaxInterval time.Duration `envconfig:"ADAPTIVE_POLLING_MAX_INTERVAL" default:"5m"`
// Logging settings
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
LogFormat string `envconfig:"LOG_FORMAT" default:"auto"` // "json", "console", or "auto"
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"`
APITokenEnabled bool `envconfig:"API_TOKEN_ENABLED" default:"false"`
APITokens []APITokenRecord `json:"-"`
AuthUser string `envconfig:"PULSE_AUTH_USER"`
AuthPass string `envconfig:"PULSE_AUTH_PASS"`
DisableAuth bool `envconfig:"DISABLE_AUTH" default:"false"`
DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
IframeEmbeddingAllow string `envconfig:"IFRAME_EMBEDDING_ALLOW" default:"SAMEORIGIN"`
// Proxy authentication settings
ProxyAuthSecret string `envconfig:"PROXY_AUTH_SECRET"`
ProxyAuthUserHeader string `envconfig:"PROXY_AUTH_USER_HEADER"`
ProxyAuthRoleHeader string `envconfig:"PROXY_AUTH_ROLE_HEADER"`
ProxyAuthRoleSeparator string `envconfig:"PROXY_AUTH_ROLE_SEPARATOR" default:"|"`
ProxyAuthAdminRole string `envconfig:"PROXY_AUTH_ADMIN_ROLE" default:"admin"`
ProxyAuthLogoutURL string `envconfig:"PROXY_AUTH_LOGOUT_URL"`
// OIDC configuration
OIDC *OIDCConfig `json:"-"`
// HTTPS/TLS settings
HTTPSEnabled bool `envconfig:"HTTPS_ENABLED" default:"false"`
TLSCertFile string `envconfig:"TLS_CERT_FILE" default:""`
TLSKeyFile string `envconfig:"TLS_KEY_FILE" default:""`
// 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
DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"false"`
DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"`
Discovery DiscoveryConfig `json:"discoveryConfig"`
// Deprecated - for backward compatibility
Port int `envconfig:"PORT"` // Maps to BackendPort
Debug bool `envconfig:"DEBUG" default:"false"`
// Track which settings are overridden by environment variables
EnvOverrides map[string]bool `json:"-"`
}
// DiscoveryConfig captures overrides for network discovery behaviour.
type DiscoveryConfig struct {
EnvironmentOverride string `json:"environment_override,omitempty"`
SubnetAllowlist []string `json:"subnet_allowlist,omitempty"`
SubnetBlocklist []string `json:"subnet_blocklist,omitempty"`
MaxHostsPerScan int `json:"max_hosts_per_scan,omitempty"`
MaxConcurrent int `json:"max_concurrent,omitempty"`
EnableReverseDNS bool `json:"enable_reverse_dns"`
ScanGateways bool `json:"scan_gateways"`
DialTimeout int `json:"dial_timeout_ms,omitempty"`
HTTPTimeout int `json:"http_timeout_ms,omitempty"`
}
// DefaultDiscoveryConfig returns opinionated defaults for discovery behaviour.
func DefaultDiscoveryConfig() DiscoveryConfig {
return DiscoveryConfig{
EnvironmentOverride: "auto",
SubnetAllowlist: nil,
SubnetBlocklist: []string{"169.254.0.0/16"},
MaxHostsPerScan: 1024,
MaxConcurrent: 50,
EnableReverseDNS: true,
ScanGateways: true,
DialTimeout: 1000,
HTTPTimeout: 2000,
}
}
// CloneDiscoveryConfig returns a deep copy of the provided discovery config.
func CloneDiscoveryConfig(cfg DiscoveryConfig) DiscoveryConfig {
clone := cfg
if cfg.SubnetAllowlist != nil {
clone.SubnetAllowlist = append([]string(nil), cfg.SubnetAllowlist...)
}
if cfg.SubnetBlocklist != nil {
clone.SubnetBlocklist = append([]string(nil), cfg.SubnetBlocklist...)
}
return clone
}
// NormalizeDiscoveryConfig ensures a discovery config contains sane values and defaults.
func NormalizeDiscoveryConfig(cfg DiscoveryConfig) DiscoveryConfig {
defaults := DefaultDiscoveryConfig()
normalized := CloneDiscoveryConfig(cfg)
// Normalize environment override and ensure it's valid.
normalized.EnvironmentOverride = strings.TrimSpace(normalized.EnvironmentOverride)
if normalized.EnvironmentOverride == "" {
normalized.EnvironmentOverride = defaults.EnvironmentOverride
} else if !IsValidDiscoveryEnvironment(normalized.EnvironmentOverride) {
log.Warn().
Str("environment", normalized.EnvironmentOverride).
Msg("Unknown discovery environment override detected; falling back to auto")
normalized.EnvironmentOverride = defaults.EnvironmentOverride
}
normalized.SubnetAllowlist = sanitizeCIDRList(normalized.SubnetAllowlist)
if normalized.SubnetAllowlist == nil {
normalized.SubnetAllowlist = []string{}
}
normalized.SubnetBlocklist = sanitizeCIDRList(normalized.SubnetBlocklist)
if normalized.SubnetBlocklist == nil {
normalized.SubnetBlocklist = append([]string(nil), defaults.SubnetBlocklist...)
}
if normalized.MaxHostsPerScan <= 0 {
normalized.MaxHostsPerScan = defaults.MaxHostsPerScan
}
if normalized.MaxConcurrent <= 0 {
normalized.MaxConcurrent = defaults.MaxConcurrent
}
if normalized.DialTimeout <= 0 {
normalized.DialTimeout = defaults.DialTimeout
}
if normalized.HTTPTimeout <= 0 {
normalized.HTTPTimeout = defaults.HTTPTimeout
}
return normalized
}
func sanitizeCIDRList(values []string) []string {
if len(values) == 0 {
return nil
}
cleaned := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, raw := range values {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
// Avoid duplicates to keep config minimal.
if _, exists := seen[entry]; exists {
continue
}
seen[entry] = struct{}{}
cleaned = append(cleaned, entry)
}
if len(cleaned) == 0 {
return []string{}
}
return cleaned
}
// UnmarshalJSON supports both legacy camelCase and new snake_case field names.
func (d *DiscoveryConfig) UnmarshalJSON(data []byte) error {
type modern struct {
EnvironmentOverride *string `json:"environment_override"`
SubnetAllowlist *[]string `json:"subnet_allowlist"`
SubnetBlocklist *[]string `json:"subnet_blocklist"`
MaxHostsPerScan *int `json:"max_hosts_per_scan"`
MaxConcurrent *int `json:"max_concurrent"`
EnableReverseDNS *bool `json:"enable_reverse_dns"`
ScanGateways *bool `json:"scan_gateways"`
DialTimeout *int `json:"dial_timeout_ms"`
HTTPTimeout *int `json:"http_timeout_ms"`
}
type legacy struct {
EnvironmentOverride *string `json:"environmentOverride"`
SubnetAllowlist *[]string `json:"subnetAllowlist"`
SubnetBlocklist *[]string `json:"subnetBlocklist"`
MaxHostsPerScan *int `json:"maxHostsPerScan"`
MaxConcurrent *int `json:"maxConcurrent"`
EnableReverseDNS *bool `json:"enableReverseDns"`
ScanGateways *bool `json:"scanGateways"`
DialTimeout *int `json:"dialTimeoutMs"`
HTTPTimeout *int `json:"httpTimeoutMs"`
}
var modernPayload modern
if err := json.Unmarshal(data, &modernPayload); err != nil {
return err
}
var legacyPayload legacy
_ = json.Unmarshal(data, &legacyPayload)
cfg := DefaultDiscoveryConfig()
if modernPayload.EnvironmentOverride != nil {
cfg.EnvironmentOverride = strings.TrimSpace(*modernPayload.EnvironmentOverride)
} else if legacyPayload.EnvironmentOverride != nil {
cfg.EnvironmentOverride = strings.TrimSpace(*legacyPayload.EnvironmentOverride)
}
switch {
case modernPayload.SubnetAllowlist != nil:
cfg.SubnetAllowlist = sanitizeCIDRList(*modernPayload.SubnetAllowlist)
case legacyPayload.SubnetAllowlist != nil:
cfg.SubnetAllowlist = sanitizeCIDRList(*legacyPayload.SubnetAllowlist)
default:
cfg.SubnetAllowlist = []string{}
}
switch {
case modernPayload.SubnetBlocklist != nil:
cfg.SubnetBlocklist = sanitizeCIDRList(*modernPayload.SubnetBlocklist)
case legacyPayload.SubnetBlocklist != nil:
cfg.SubnetBlocklist = sanitizeCIDRList(*legacyPayload.SubnetBlocklist)
}
if modernPayload.MaxHostsPerScan != nil {
cfg.MaxHostsPerScan = *modernPayload.MaxHostsPerScan
} else if legacyPayload.MaxHostsPerScan != nil {
cfg.MaxHostsPerScan = *legacyPayload.MaxHostsPerScan
}
if modernPayload.MaxConcurrent != nil {
cfg.MaxConcurrent = *modernPayload.MaxConcurrent
} else if legacyPayload.MaxConcurrent != nil {
cfg.MaxConcurrent = *legacyPayload.MaxConcurrent
}
if modernPayload.EnableReverseDNS != nil {
cfg.EnableReverseDNS = *modernPayload.EnableReverseDNS
} else if legacyPayload.EnableReverseDNS != nil {
cfg.EnableReverseDNS = *legacyPayload.EnableReverseDNS
}
if modernPayload.ScanGateways != nil {
cfg.ScanGateways = *modernPayload.ScanGateways
} else if legacyPayload.ScanGateways != nil {
cfg.ScanGateways = *legacyPayload.ScanGateways
}
if modernPayload.DialTimeout != nil {
cfg.DialTimeout = *modernPayload.DialTimeout
} else if legacyPayload.DialTimeout != nil {
cfg.DialTimeout = *legacyPayload.DialTimeout
}
if modernPayload.HTTPTimeout != nil {
cfg.HTTPTimeout = *modernPayload.HTTPTimeout
} else if legacyPayload.HTTPTimeout != nil {
cfg.HTTPTimeout = *legacyPayload.HTTPTimeout
}
*d = NormalizeDiscoveryConfig(cfg)
return nil
}
// IsValidDiscoveryEnvironment reports whether the supplied override is recognised.
func IsValidDiscoveryEnvironment(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "auto", "native", "docker_host", "docker_bridge", "lxc_privileged", "lxc_unprivileged":
return true
default:
return false
}
}
func splitAndTrim(value string) []string {
if value == "" {
return []string{}
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// 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
MonitorPhysicalDisks *bool // Monitor physical disks (nil = enabled by default, can be explicitly disabled)
PhysicalDiskPollingMinutes int // How often to poll physical disks (0 = use default)
// 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
}
// PMGInstance represents a Proxmox Mail Gateway connection
type PMGInstance struct {
Name string
Host string
User string
Password string
TokenName string
TokenValue string
Fingerprint string
VerifySSL bool
MonitorMailStats bool
MonitorQueues bool
MonitorQuarantine bool
MonitorDomainStats 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")
}
// Load mock.env and mock.env.local for mock mode configuration
mockEnv := "mock.env"
mockEnvLocal := "mock.env.local"
if _, err := os.Stat(mockEnv); err == nil {
if err := godotenv.Load(mockEnv); err != nil {
log.Warn().Err(err).Str("file", mockEnv).Msg("Failed to load mock.env file")
} else {
log.Info().Str("file", mockEnv).Msg("Loaded mock mode configuration")
}
}
if _, err := os.Stat(mockEnvLocal); err == nil {
if err := godotenv.Load(mockEnvLocal); err != nil {
log.Warn().Err(err).Str("file", mockEnvLocal).Msg("Failed to load mock.env.local file")
} else {
log.Info().Str("file", mockEnvLocal).Msg("Loaded local mock mode overrides")
}
}
// 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: 60 * time.Second,
MetricsRetentionDays: 7,
BackupPollingCycles: 10,
BackupPollingInterval: 0,
EnableBackupPolling: true,
WebhookBatchDelay: 10 * time.Second,
AdaptivePollingEnabled: false,
AdaptivePollingBaseInterval: 10 * time.Second,
AdaptivePollingMinInterval: 5 * time.Second,
AdaptivePollingMaxInterval: 5 * time.Minute,
LogLevel: "info",
LogFormat: "auto",
LogMaxSize: 100,
LogMaxAge: 30,
LogCompress: true,
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
IframeEmbeddingAllow: "SAMEORIGIN",
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
DiscoveryEnabled: false,
DiscoverySubnet: "auto",
EnvOverrides: make(map[string]bool),
OIDC: NewOIDCConfig(),
}
cfg.Discovery = DefaultDiscoveryConfig()
// Initialize persistence
persistence := NewConfigPersistence(dataDir)
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
cfg.PMGInstances = nodesConfig.PMGInstances
log.Info().
Int("pve", len(cfg.PVEInstances)).
Int("pbs", len(cfg.PBSInstances)).
Int("pmg", len(cfg.PMGInstances)).
Msg("Loaded nodes configuration")
} else if err != nil {
log.Warn().Err(err).Msg("Failed to load nodes configuration")
}
// Load system configuration
if systemSettings, err := persistence.LoadSystemSettings(); err == nil && systemSettings != nil {
// Load PBS polling interval if configured
if systemSettings.PBSPollingInterval > 0 {
cfg.PBSPollingInterval = time.Duration(systemSettings.PBSPollingInterval) * time.Second
}
if systemSettings.PMGPollingInterval > 0 {
cfg.PMGPollingInterval = time.Duration(systemSettings.PMGPollingInterval) * time.Second
}
if systemSettings.BackupPollingInterval > 0 {
cfg.BackupPollingInterval = time.Duration(systemSettings.BackupPollingInterval) * time.Second
} else if systemSettings.BackupPollingInterval == 0 {
cfg.BackupPollingInterval = 0
}
if systemSettings.BackupPollingEnabled != nil {
cfg.EnableBackupPolling = *systemSettings.BackupPollingEnabled
}
if systemSettings.AdaptivePollingEnabled != nil {
cfg.AdaptivePollingEnabled = *systemSettings.AdaptivePollingEnabled
}
if systemSettings.AdaptivePollingBaseInterval > 0 {
cfg.AdaptivePollingBaseInterval = time.Duration(systemSettings.AdaptivePollingBaseInterval) * time.Second
}
if systemSettings.AdaptivePollingMinInterval > 0 {
cfg.AdaptivePollingMinInterval = time.Duration(systemSettings.AdaptivePollingMinInterval) * time.Second
}
if systemSettings.AdaptivePollingMaxInterval > 0 {
cfg.AdaptivePollingMaxInterval = time.Duration(systemSettings.AdaptivePollingMaxInterval) * 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
}
if systemSettings.LogLevel != "" {
cfg.LogLevel = systemSettings.LogLevel
}
// Always load DiscoveryEnabled even if false
cfg.DiscoveryEnabled = systemSettings.DiscoveryEnabled
if systemSettings.DiscoverySubnet != "" {
cfg.DiscoverySubnet = systemSettings.DiscoverySubnet
}
cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig))
// APIToken no longer loaded from system.json - only from .env
log.Info().
Str("updateChannel", cfg.UpdateChannel).
Str("logLevel", cfg.LogLevel).
Msg("Loaded system configuration")
} else {
// No system.json exists - create default one
log.Info().Msg("No system.json found, creating default")
defaultSettings := DefaultSystemSettings()
defaultSettings.ConnectionTimeout = int(cfg.ConnectionTimeout.Seconds())
if err := persistence.SaveSystemSettings(*defaultSettings); err != nil {
log.Warn().Err(err).Msg("Failed to create default system.json")
}
}
if oidcSettings, err := persistence.LoadOIDCConfig(); err == nil && oidcSettings != nil {
cfg.OIDC = oidcSettings
} else if err != nil {
log.Warn().Err(err).Msg("Failed to load OIDC configuration")
}
}
// Load API tokens
if tokens, err := persistence.LoadAPITokens(); err == nil {
cfg.APITokens = tokens
cfg.SortAPITokens()
log.Info().Int("count", len(tokens)).Msg("Loaded API tokens from persistence")
} else if err != nil {
log.Warn().Err(err).Msg("Failed to load API tokens from persistence")
}
// Ensure PBS polling interval has default if not set
// Note: PVE polling is hardcoded to 10s in monitor.go
if cfg.PBSPollingInterval == 0 {
cfg.PBSPollingInterval = 60 * time.Second
}
if cfg.PMGPollingInterval == 0 {
cfg.PMGPollingInterval = 60 * time.Second
}
// Limited environment variable support
// NOTE: Node configuration is NOT done via env vars - use the web UI instead
if cyclesStr := strings.TrimSpace(os.Getenv("BACKUP_POLLING_CYCLES")); cyclesStr != "" {
if cycles, err := strconv.Atoi(cyclesStr); err == nil {
if cycles < 0 {
log.Warn().Str("value", cyclesStr).Msg("Ignoring negative BACKUP_POLLING_CYCLES from environment")
} else {
cfg.BackupPollingCycles = cycles
cfg.EnvOverrides["BACKUP_POLLING_CYCLES"] = true
log.Info().Int("cycles", cycles).Msg("Overriding backup polling cycles from environment")
}
} else {
log.Warn().Str("value", cyclesStr).Msg("Invalid BACKUP_POLLING_CYCLES value, ignoring")
}
}
if intervalStr := strings.TrimSpace(os.Getenv("BACKUP_POLLING_INTERVAL")); intervalStr != "" {
if dur, err := time.ParseDuration(intervalStr); err == nil {
if dur < 0 {
log.Warn().Str("value", intervalStr).Msg("Ignoring negative BACKUP_POLLING_INTERVAL from environment")
} else {
cfg.BackupPollingInterval = dur
cfg.EnvOverrides["BACKUP_POLLING_INTERVAL"] = true
log.Info().Dur("interval", dur).Msg("Overriding backup polling interval from environment")
}
} else if seconds, err := strconv.Atoi(intervalStr); err == nil {
if seconds < 0 {
log.Warn().Str("value", intervalStr).Msg("Ignoring negative BACKUP_POLLING_INTERVAL (seconds) from environment")
} else {
cfg.BackupPollingInterval = time.Duration(seconds) * time.Second
cfg.EnvOverrides["BACKUP_POLLING_INTERVAL"] = true
log.Info().Int("seconds", seconds).Msg("Overriding backup polling interval (seconds) from environment")
}
} else {
log.Warn().Str("value", intervalStr).Msg("Invalid BACKUP_POLLING_INTERVAL value, expected duration or seconds")
}
}
if enabledStr := strings.TrimSpace(os.Getenv("ENABLE_BACKUP_POLLING")); enabledStr != "" {
switch strings.ToLower(enabledStr) {
case "0", "false", "no", "off":
cfg.EnableBackupPolling = false
default:
cfg.EnableBackupPolling = true
}
cfg.EnvOverrides["ENABLE_BACKUP_POLLING"] = true
log.Info().Bool("enabled", cfg.EnableBackupPolling).Msg("Overriding backup polling enabled flag from environment")
}
if adaptiveEnabled := strings.TrimSpace(os.Getenv("ADAPTIVE_POLLING_ENABLED")); adaptiveEnabled != "" {
switch strings.ToLower(adaptiveEnabled) {
case "0", "false", "no", "off":
cfg.AdaptivePollingEnabled = false
default:
cfg.AdaptivePollingEnabled = true
}
cfg.EnvOverrides["ADAPTIVE_POLLING_ENABLED"] = true
log.Info().Bool("enabled", cfg.AdaptivePollingEnabled).Msg("Adaptive polling feature flag overridden by environment")
}
if baseInterval := strings.TrimSpace(os.Getenv("ADAPTIVE_POLLING_BASE_INTERVAL")); baseInterval != "" {
if dur, err := time.ParseDuration(baseInterval); err == nil {
cfg.AdaptivePollingBaseInterval = dur
cfg.EnvOverrides["ADAPTIVE_POLLING_BASE_INTERVAL"] = true
log.Info().Dur("interval", dur).Msg("Adaptive polling base interval overridden by environment")
} else {
log.Warn().Str("value", baseInterval).Msg("Invalid ADAPTIVE_POLLING_BASE_INTERVAL value, expected duration string")
}
}
if minInterval := strings.TrimSpace(os.Getenv("ADAPTIVE_POLLING_MIN_INTERVAL")); minInterval != "" {
if dur, err := time.ParseDuration(minInterval); err == nil {
cfg.AdaptivePollingMinInterval = dur
cfg.EnvOverrides["ADAPTIVE_POLLING_MIN_INTERVAL"] = true
log.Info().Dur("interval", dur).Msg("Adaptive polling min interval overridden by environment")
} else {
log.Warn().Str("value", minInterval).Msg("Invalid ADAPTIVE_POLLING_MIN_INTERVAL value, expected duration string")
}
}
if maxInterval := strings.TrimSpace(os.Getenv("ADAPTIVE_POLLING_MAX_INTERVAL")); maxInterval != "" {
if dur, err := time.ParseDuration(maxInterval); err == nil {
cfg.AdaptivePollingMaxInterval = dur
cfg.EnvOverrides["ADAPTIVE_POLLING_MAX_INTERVAL"] = true
log.Info().Dur("interval", dur).Msg("Adaptive polling max interval overridden by environment")
} else {
log.Warn().Str("value", maxInterval).Msg("Invalid ADAPTIVE_POLLING_MAX_INTERVAL value, expected duration string")
}
}
// Support both FRONTEND_PORT (preferred) and PORT (legacy) env vars
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
if p, err := strconv.Atoi(frontendPort); err == nil {
cfg.FrontendPort = p
log.Info().Int("port", p).Msg("Overriding frontend port from FRONTEND_PORT env var")
}
} else if port := os.Getenv("PORT"); port != "" {
// Fall back to PORT for backwards compatibility
if p, err := strconv.Atoi(port); err == nil {
cfg.FrontendPort = p
log.Info().Int("port", p).Msg("Overriding frontend port from PORT env var (legacy)")
}
}
envTokens := make([]string, 0, 4)
if list := strings.TrimSpace(os.Getenv("API_TOKENS")); list != "" {
for _, part := range strings.Split(list, ",") {
part = strings.TrimSpace(part)
if part != "" {
envTokens = append(envTokens, part)
}
}
}
if token := strings.TrimSpace(os.Getenv("API_TOKEN")); token != "" {
envTokens = append(envTokens, token)
}
if len(envTokens) > 0 {
cfg.EnvOverrides["API_TOKEN"] = true
cfg.EnvOverrides["API_TOKENS"] = true
for _, tokenValue := range envTokens {
if tokenValue == "" {
continue
}
hashed := tokenValue
prefix := tokenPrefix(tokenValue)
suffix := tokenSuffix(tokenValue)
if !auth.IsAPITokenHashed(tokenValue) {
hashed = auth.HashAPIToken(tokenValue)
prefix = tokenPrefix(tokenValue)
suffix = tokenSuffix(tokenValue)
log.Info().Msg("Auto-hashed plain text API token from environment variable")
} else {
log.Debug().Msg("Loaded pre-hashed API token from env var")
}
if cfg.HasAPITokenHash(hashed) {
continue
}
record := APITokenRecord{
ID: uuid.NewString(),
Name: "Environment token",
Hash: hashed,
Prefix: prefix,
Suffix: suffix,
CreatedAt: time.Now().UTC(),
Scopes: []string{ScopeWildcard},
}
cfg.APITokens = append(cfg.APITokens, record)
}
cfg.SortAPITokens()
}
// Check if API token is enabled
if apiTokenEnabled := os.Getenv("API_TOKEN_ENABLED"); apiTokenEnabled != "" {
cfg.APITokenEnabled = apiTokenEnabled == "true" || apiTokenEnabled == "1"
log.Debug().Bool("enabled", cfg.APITokenEnabled).Msg("API token enabled status from env var")
} else if cfg.HasAPITokens() {
cfg.APITokenEnabled = true
log.Debug().Msg("API tokens exist without explicit enabled flag, assuming enabled for backwards compatibility")
}
// Legacy migration: if a single token is present without metadata, wrap it.
if !cfg.HasAPITokens() && cfg.APIToken != "" {
if record, err := NewHashedAPITokenRecord(cfg.APIToken, "Legacy token", time.Now().UTC(), nil); err == nil {
cfg.APITokens = []APITokenRecord{*record}
cfg.SortAPITokens()
log.Info().Msg("Migrated legacy API token into token record store")
}
}
// Check if auth is disabled
disableAuthEnv := os.Getenv("DISABLE_AUTH")
log.Debug().Str("DISABLE_AUTH_ENV", disableAuthEnv).Msg("Checking DISABLE_AUTH environment variable")
if disableAuthEnv != "" {
cfg.DisableAuth = disableAuthEnv == "true" || disableAuthEnv == "1"
log.Debug().Bool("DisableAuth", cfg.DisableAuth).Msg("DisableAuth set from environment")
if cfg.DisableAuth {
log.Warn().Msg("⚠️ AUTHENTICATION DISABLED - Pulse is running without authentication!")
}
} else {
log.Debug().Bool("DisableAuth", cfg.DisableAuth).Msg("DISABLE_AUTH not set, DisableAuth remains")
}
// Check if demo mode is enabled
demoModeEnv := os.Getenv("DEMO_MODE")
if demoModeEnv != "" {
cfg.DemoMode = demoModeEnv == "true" || demoModeEnv == "1"
if cfg.DemoMode {
log.Warn().Msg("🎭 DEMO MODE - All modifications disabled (read-only)")
}
}
// Load proxy authentication settings
if proxyAuthSecret := os.Getenv("PROXY_AUTH_SECRET"); proxyAuthSecret != "" {
cfg.ProxyAuthSecret = proxyAuthSecret
log.Info().Msg("Proxy authentication secret configured")
// Load other proxy auth settings
if userHeader := os.Getenv("PROXY_AUTH_USER_HEADER"); userHeader != "" {
cfg.ProxyAuthUserHeader = userHeader
log.Info().Str("header", userHeader).Msg("Proxy auth user header configured")
}
if roleHeader := os.Getenv("PROXY_AUTH_ROLE_HEADER"); roleHeader != "" {
cfg.ProxyAuthRoleHeader = roleHeader
log.Info().Str("header", roleHeader).Msg("Proxy auth role header configured")
}
if roleSeparator := os.Getenv("PROXY_AUTH_ROLE_SEPARATOR"); roleSeparator != "" {
cfg.ProxyAuthRoleSeparator = roleSeparator
log.Info().Str("separator", roleSeparator).Msg("Proxy auth role separator configured")
}
if adminRole := os.Getenv("PROXY_AUTH_ADMIN_ROLE"); adminRole != "" {
cfg.ProxyAuthAdminRole = adminRole
log.Info().Str("role", adminRole).Msg("Proxy auth admin role configured")
}
if logoutURL := os.Getenv("PROXY_AUTH_LOGOUT_URL"); logoutURL != "" {
cfg.ProxyAuthLogoutURL = logoutURL
log.Info().Str("url", logoutURL).Msg("Proxy auth logout URL configured")
}
}
oidcEnv := make(map[string]string)
if val := os.Getenv("OIDC_ENABLED"); val != "" {
oidcEnv["OIDC_ENABLED"] = val
}
if val := os.Getenv("OIDC_ISSUER_URL"); val != "" {
oidcEnv["OIDC_ISSUER_URL"] = val
}
if val := os.Getenv("OIDC_CLIENT_ID"); val != "" {
oidcEnv["OIDC_CLIENT_ID"] = val
}
if val := os.Getenv("OIDC_CLIENT_SECRET"); val != "" {
oidcEnv["OIDC_CLIENT_SECRET"] = val
}
if val := os.Getenv("OIDC_REDIRECT_URL"); val != "" {
oidcEnv["OIDC_REDIRECT_URL"] = val
}
if val := os.Getenv("OIDC_LOGOUT_URL"); val != "" {
oidcEnv["OIDC_LOGOUT_URL"] = val
}
if val := os.Getenv("OIDC_SCOPES"); val != "" {
oidcEnv["OIDC_SCOPES"] = val
}
if val := os.Getenv("OIDC_USERNAME_CLAIM"); val != "" {
oidcEnv["OIDC_USERNAME_CLAIM"] = val
}
if val := os.Getenv("OIDC_EMAIL_CLAIM"); val != "" {
oidcEnv["OIDC_EMAIL_CLAIM"] = val
}
if val := os.Getenv("OIDC_GROUPS_CLAIM"); val != "" {
oidcEnv["OIDC_GROUPS_CLAIM"] = val
}
if val := os.Getenv("OIDC_ALLOWED_GROUPS"); val != "" {
oidcEnv["OIDC_ALLOWED_GROUPS"] = val
}
if val := os.Getenv("OIDC_ALLOWED_DOMAINS"); val != "" {
oidcEnv["OIDC_ALLOWED_DOMAINS"] = val
}
if val := os.Getenv("OIDC_ALLOWED_EMAILS"); val != "" {
oidcEnv["OIDC_ALLOWED_EMAILS"] = val
}
if len(oidcEnv) > 0 {
cfg.OIDC.MergeFromEnv(oidcEnv)
}
if authUser := os.Getenv("PULSE_AUTH_USER"); authUser != "" {
cfg.AuthUser = authUser
log.Info().Msg("Overriding auth user from env var")
}
if authPass := os.Getenv("PULSE_AUTH_PASS"); authPass != "" {
// Auto-hash plain text passwords for security
if !IsPasswordHashed(authPass) {
// Plain text password - hash it immediately
hashedPass, err := auth.HashPassword(authPass)
if err != nil {
log.Error().Err(err).Msg("Failed to hash password from environment variable")
// Fall back to plain text if hashing fails (shouldn't happen)
cfg.AuthPass = authPass
} else {
cfg.AuthPass = hashedPass
log.Info().Msg("Auto-hashed plain text password from environment variable")
}
} else {
// Already hashed - validate it's complete
if len(authPass) != 60 {
log.Error().Int("length", len(authPass)).Msg("Bcrypt hash appears truncated! Expected 60 characters. Authentication may fail.")
log.Error().Msg("Ensure the full hash is enclosed in single quotes in your .env file or Docker environment")
}
cfg.AuthPass = authPass
log.Debug().Msg("Loaded pre-hashed password from env var")
}
}
// HTTPS/TLS configuration from environment
if httpsEnabled := os.Getenv("HTTPS_ENABLED"); httpsEnabled != "" {
cfg.HTTPSEnabled = httpsEnabled == "true" || httpsEnabled == "1"
log.Debug().Bool("enabled", cfg.HTTPSEnabled).Msg("HTTPS enabled status from env var")
}
if tlsCertFile := os.Getenv("TLS_CERT_FILE"); tlsCertFile != "" {
cfg.TLSCertFile = tlsCertFile
log.Debug().Str("cert_file", tlsCertFile).Msg("TLS cert file from env var")
}
if tlsKeyFile := os.Getenv("TLS_KEY_FILE"); tlsKeyFile != "" {
cfg.TLSKeyFile = tlsKeyFile
log.Debug().Str("key_file", tlsKeyFile).Msg("TLS key file from env var")
}
// REMOVED: Update channel, auto-update, connection timeout, and allowed origins env vars
// These settings now ONLY come from system.json to prevent confusion
// Only keeping essential deployment/infrastructure env vars
// Normalize PVE user fields for password authentication
for i := range cfg.PVEInstances {
if cfg.PVEInstances[i].TokenName == "" && cfg.PVEInstances[i].TokenValue == "" && cfg.PVEInstances[i].User != "" && !strings.Contains(cfg.PVEInstances[i].User, "@") {
cfg.PVEInstances[i].User = cfg.PVEInstances[i].User + "@pam"
}
}
if cfg.AllowedOrigins == "" {
// If not configured and we're in development mode (different ports for frontend/backend)
// allow localhost for development convenience
if os.Getenv("NODE_ENV") == "development" || os.Getenv("PULSE_DEV") == "true" {
cfg.AllowedOrigins = "http://localhost:5173,http://localhost:7655"
log.Info().Msg("Development mode: allowing localhost origins")
}
}
// Support env vars for important settings (override system.json)
// NOTE: Environment variables always take precedence over UI/system.json settings
if discoveryEnabled := os.Getenv("DISCOVERY_ENABLED"); discoveryEnabled != "" {
cfg.DiscoveryEnabled = discoveryEnabled == "true" || discoveryEnabled == "1"
cfg.EnvOverrides["discoveryEnabled"] = true
log.Info().Bool("enabled", cfg.DiscoveryEnabled).Msg("Discovery enabled overridden by DISCOVERY_ENABLED env var")
}
if discoverySubnet := os.Getenv("DISCOVERY_SUBNET"); discoverySubnet != "" {
cfg.DiscoverySubnet = discoverySubnet
cfg.EnvOverrides["discoverySubnet"] = true
log.Info().Str("subnet", discoverySubnet).Msg("Discovery subnet overridden by DISCOVERY_SUBNET env var")
}
if envOverride := strings.TrimSpace(os.Getenv("DISCOVERY_ENVIRONMENT_OVERRIDE")); envOverride != "" {
if IsValidDiscoveryEnvironment(envOverride) {
cfg.Discovery.EnvironmentOverride = strings.ToLower(envOverride)
cfg.EnvOverrides["discoveryEnvironmentOverride"] = true
log.Info().Str("environment", cfg.Discovery.EnvironmentOverride).Msg("Discovery environment override set by DISCOVERY_ENVIRONMENT_OVERRIDE")
} else {
log.Warn().Str("value", envOverride).Msg("Ignoring invalid DISCOVERY_ENVIRONMENT_OVERRIDE value")
}
}
if allowlistEnv := strings.TrimSpace(os.Getenv("DISCOVERY_SUBNET_ALLOWLIST")); allowlistEnv != "" {
parts := splitAndTrim(allowlistEnv)
cfg.Discovery.SubnetAllowlist = sanitizeCIDRList(parts)
cfg.EnvOverrides["discoverySubnetAllowlist"] = true
log.Info().Int("allowlistCount", len(cfg.Discovery.SubnetAllowlist)).Msg("Discovery subnet allowlist overridden by DISCOVERY_SUBNET_ALLOWLIST")
}
if blocklistEnv := strings.TrimSpace(os.Getenv("DISCOVERY_SUBNET_BLOCKLIST")); blocklistEnv != "" {
parts := splitAndTrim(blocklistEnv)
cfg.Discovery.SubnetBlocklist = sanitizeCIDRList(parts)
cfg.EnvOverrides["discoverySubnetBlocklist"] = true
log.Info().Int("blocklistCount", len(cfg.Discovery.SubnetBlocklist)).Msg("Discovery subnet blocklist overridden by DISCOVERY_SUBNET_BLOCKLIST")
}
if maxHostsEnv := strings.TrimSpace(os.Getenv("DISCOVERY_MAX_HOSTS_PER_SCAN")); maxHostsEnv != "" {
if v, err := strconv.Atoi(maxHostsEnv); err == nil && v > 0 {
cfg.Discovery.MaxHostsPerScan = v
cfg.EnvOverrides["discoveryMaxHostsPerScan"] = true
log.Info().Int("maxHostsPerScan", v).Msg("Discovery max hosts per scan overridden by DISCOVERY_MAX_HOSTS_PER_SCAN")
} else {
log.Warn().Str("value", maxHostsEnv).Msg("Ignoring invalid DISCOVERY_MAX_HOSTS_PER_SCAN value")
}
}
if maxConcurrentEnv := strings.TrimSpace(os.Getenv("DISCOVERY_MAX_CONCURRENT")); maxConcurrentEnv != "" {
if v, err := strconv.Atoi(maxConcurrentEnv); err == nil && v > 0 {
cfg.Discovery.MaxConcurrent = v
cfg.EnvOverrides["discoveryMaxConcurrent"] = true
log.Info().Int("maxConcurrent", v).Msg("Discovery concurrency overridden by DISCOVERY_MAX_CONCURRENT")
} else {
log.Warn().Str("value", maxConcurrentEnv).Msg("Ignoring invalid DISCOVERY_MAX_CONCURRENT value")
}
}
if reverseDNSEnv := strings.TrimSpace(os.Getenv("DISCOVERY_ENABLE_REVERSE_DNS")); reverseDNSEnv != "" {
switch strings.ToLower(reverseDNSEnv) {
case "0", "false", "no", "off":
cfg.Discovery.EnableReverseDNS = false
default:
cfg.Discovery.EnableReverseDNS = true
}
cfg.EnvOverrides["discoveryEnableReverseDns"] = true
log.Info().Bool("enableReverseDNS", cfg.Discovery.EnableReverseDNS).Msg("Discovery reverse DNS setting overridden by DISCOVERY_ENABLE_REVERSE_DNS")
}
if scanGatewaysEnv := strings.TrimSpace(os.Getenv("DISCOVERY_SCAN_GATEWAYS")); scanGatewaysEnv != "" {
switch strings.ToLower(scanGatewaysEnv) {
case "0", "false", "no", "off":
cfg.Discovery.ScanGateways = false
default:
cfg.Discovery.ScanGateways = true
}
cfg.EnvOverrides["discoveryScanGateways"] = true
log.Info().Bool("scanGateways", cfg.Discovery.ScanGateways).Msg("Discovery gateway scanning overridden by DISCOVERY_SCAN_GATEWAYS")
}
if dialTimeoutEnv := strings.TrimSpace(os.Getenv("DISCOVERY_DIAL_TIMEOUT_MS")); dialTimeoutEnv != "" {
if v, err := strconv.Atoi(dialTimeoutEnv); err == nil && v > 0 {
cfg.Discovery.DialTimeout = v
cfg.EnvOverrides["discoveryDialTimeoutMs"] = true
log.Info().Int("dialTimeoutMs", v).Msg("Discovery dial timeout overridden by DISCOVERY_DIAL_TIMEOUT_MS")
} else {
log.Warn().Str("value", dialTimeoutEnv).Msg("Ignoring invalid DISCOVERY_DIAL_TIMEOUT_MS value")
}
}
if httpTimeoutEnv := strings.TrimSpace(os.Getenv("DISCOVERY_HTTP_TIMEOUT_MS")); httpTimeoutEnv != "" {
if v, err := strconv.Atoi(httpTimeoutEnv); err == nil && v > 0 {
cfg.Discovery.HTTPTimeout = v
cfg.EnvOverrides["discoveryHttpTimeoutMs"] = true
log.Info().Int("httpTimeoutMs", v).Msg("Discovery HTTP timeout overridden by DISCOVERY_HTTP_TIMEOUT_MS")
} else {
log.Warn().Str("value", httpTimeoutEnv).Msg("Ignoring invalid DISCOVERY_HTTP_TIMEOUT_MS value")
}
}
if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" {
cfg.LogLevel = logLevel
cfg.EnvOverrides["logLevel"] = true
log.Info().Str("level", logLevel).Msg("Log level overridden by LOG_LEVEL env var")
}
if logFormat := os.Getenv("LOG_FORMAT"); logFormat != "" {
cfg.LogFormat = logFormat
cfg.EnvOverrides["logFormat"] = true
log.Info().Str("format", logFormat).Msg("Log format overridden by LOG_FORMAT env var")
}
cfg.Discovery = NormalizeDiscoveryConfig(cfg.Discovery)
if connectionTimeout := os.Getenv("CONNECTION_TIMEOUT"); connectionTimeout != "" {
if d, err := time.ParseDuration(connectionTimeout + "s"); err == nil {
cfg.ConnectionTimeout = d
cfg.EnvOverrides["connectionTimeout"] = true
log.Info().Dur("timeout", d).Msg("Connection timeout overridden by CONNECTION_TIMEOUT env var")
} else if d, err := time.ParseDuration(connectionTimeout); err == nil {
cfg.ConnectionTimeout = d
cfg.EnvOverrides["connectionTimeout"] = true
log.Info().Dur("timeout", d).Msg("Connection timeout overridden by CONNECTION_TIMEOUT env var")
}
}
if allowedOrigins := os.Getenv("ALLOWED_ORIGINS"); allowedOrigins != "" {
cfg.AllowedOrigins = allowedOrigins
cfg.EnvOverrides["allowedOrigins"] = true
log.Info().Str("origins", allowedOrigins).Msg("Allowed origins overridden by ALLOWED_ORIGINS env var")
}
if publicURL := os.Getenv("PULSE_PUBLIC_URL"); publicURL != "" {
cfg.PublicURL = publicURL
cfg.EnvOverrides["publicURL"] = true
log.Info().Str("url", publicURL).Msg("Public URL configured from PULSE_PUBLIC_URL env var")
} else {
// Try to auto-detect public URL if not explicitly configured
if detectedURL := detectPublicURL(cfg.FrontendPort); detectedURL != "" {
cfg.PublicURL = detectedURL
log.Info().Str("url", detectedURL).Msg("Auto-detected public URL for webhook notifications")
}
}
cfg.OIDC.ApplyDefaults(cfg.PublicURL)
// Initialize logging with configuration values
logging.Init(logging.Config{
Format: cfg.LogFormat,
Level: cfg.LogLevel,
Component: "pulse-config",
})
// Validate configuration
if err := cfg.Validate(); err != nil {
return nil, errors.Wrap(err, "load config: invalid configuration")
}
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, cfg.PMGInstances); err != nil {
return fmt.Errorf("failed to save nodes config: %w", err)
}
// Save system configuration
adaptiveEnabled := cfg.AdaptivePollingEnabled
systemSettings := SystemSettings{
// Note: PVE polling is hardcoded to 10s
UpdateChannel: cfg.UpdateChannel,
AutoUpdateEnabled: cfg.AutoUpdateEnabled,
AutoUpdateCheckInterval: int(cfg.AutoUpdateCheckInterval.Hours()),
AutoUpdateTime: cfg.AutoUpdateTime,
AllowedOrigins: cfg.AllowedOrigins,
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()),
LogLevel: cfg.LogLevel,
DiscoveryEnabled: cfg.DiscoveryEnabled,
DiscoverySubnet: cfg.DiscoverySubnet,
DiscoveryConfig: CloneDiscoveryConfig(cfg.Discovery),
AdaptivePollingEnabled: &adaptiveEnabled,
AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second),
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
// APIToken removed - now handled via .env only
}
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
return fmt.Errorf("failed to save system config: %w", err)
}
return nil
}
// SaveOIDCConfig persists OIDC settings using the shared config persistence layer.
func SaveOIDCConfig(settings *OIDCConfig) error {
if globalPersistence == nil {
return fmt.Errorf("config persistence not initialized")
}
if settings == nil {
return fmt.Errorf("oidc settings cannot be nil")
}
clone := settings.Clone()
if clone == nil {
return fmt.Errorf("failed to clone oidc settings")
}
return globalPersistence.SaveOIDCConfig(*clone)
}
// 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
// Note: PVE polling is hardcoded to 10s
if c.ConnectionTimeout < time.Second {
return fmt.Errorf("connection timeout must be at least 1 second")
}
if c.AdaptivePollingMinInterval <= 0 {
return fmt.Errorf("adaptive polling min interval must be greater than 0")
}
if c.AdaptivePollingBaseInterval <= 0 {
return fmt.Errorf("adaptive polling base interval must be greater than 0")
}
if c.AdaptivePollingMaxInterval <= 0 {
return fmt.Errorf("adaptive polling max interval must be greater than 0")
}
if c.AdaptivePollingMinInterval > c.AdaptivePollingMaxInterval {
return fmt.Errorf("adaptive polling min interval cannot exceed max interval")
}
if c.AdaptivePollingBaseInterval < c.AdaptivePollingMinInterval || c.AdaptivePollingBaseInterval > c.AdaptivePollingMaxInterval {
return fmt.Errorf("adaptive polling base interval must be between min and max intervals")
}
// 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
if err := c.OIDC.Validate(); err != nil {
return err
}
return nil
}
// detectPublicURL attempts to automatically detect the public URL for Pulse
func detectPublicURL(port int) string {
// When running inside Docker we can't reliably determine an externally reachable address.
// Returning an empty string avoids surfacing container-only IPs (e.g., 172.x) in notifications.
if _, err := os.Stat("/.dockerenv"); err == nil {
log.Info().Msg("Docker environment detected - skipping public URL auto-detect. Set PULSE_PUBLIC_URL to expose external links.")
return ""
}
// Method 1: Check if we're in a Proxmox container (most common deployment)
if _, err := os.Stat("/etc/pve"); err == nil {
// We're likely in a ProxmoxVE container
// Try to get the container's IP from hostname -I
if output, err := exec.Command("hostname", "-I").Output(); err == nil {
ips := strings.Fields(string(output))
for _, ip := range ips {
// Skip localhost and IPv6
if !strings.HasPrefix(ip, "127.") && !strings.Contains(ip, ":") {
return fmt.Sprintf("http://%s:%d", ip, port)
}
}
}
}
// Method 2: Try to get the primary network interface IP
if ip := getOutboundIP(); ip != "" {
return fmt.Sprintf("http://%s:%d", ip, port)
}
// Method 3: Get all non-loopback IPs and use the first private one
if addrs, err := net.InterfaceAddrs(); err == nil {
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
ip := ipnet.IP.String()
// Prefer private IPs (RFC1918)
if strings.HasPrefix(ip, "192.168.") ||
strings.HasPrefix(ip, "10.") ||
strings.HasPrefix(ip, "172.") {
return fmt.Sprintf("http://%s:%d", ip, port)
}
}
}
}
// If no private IP found, use the first public one
for _, addr := range addrs {
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
if ipnet.IP.To4() != nil {
return fmt.Sprintf("http://%s:%d", ipnet.IP.String(), port)
}
}
}
}
return ""
}
// getOutboundIP gets the preferred outbound IP of this machine
func getOutboundIP() string {
// Try to connect to a public DNS server (doesn't actually connect, just resolves the route)
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
// Try Cloudflare DNS as fallback
conn, err = net.Dial("udp", "1.1.1.1:80")
if err != nil {
return ""
}
}
defer conn.Close()
localAddr := conn.LocalAddr().(*net.UDPAddr)
return localAddr.IP.String()
}