Files
pulse/internal/config/config.go
T
courtmanr@gmail.com 193c96afc3 Remove dead autoUpdateCheckInterval and autoUpdateTime settings
Found while triaging #1643 and #1637: both fields were stored via
system.json and accepted, validated, and echoed by the settings API,
but nothing ever consumed them. The unattended update schedule is owned
entirely by the systemd timer install.sh renders, and no UI control ever
set the fields, so the API persisted a schedule preference that could
never take effect.

Remove the fields from config.Config, SystemSettings, the settings
handlers, and the frontend config type, along with the interval
validation and the .env AUTO_UPDATE_CHECK_INTERVAL rewrite (a legacy
line is now preserved verbatim). Legacy clients that still send the
keys get them silently ignored instead of validated, and a system.json
written before the removal still loads cleanly - both behaviors are
pinned by new tests. User docs no longer describe the phantom schedule
settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 11:13:37 +01:00

1886 lines
77 KiB
Go

// Package config manages Pulse configuration from multiple sources.
//
// Configuration File Separation:
// - .env: Authentication credentials ONLY (PULSE_AUTH_USER, PULSE_AUTH_PASS)
// - 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"
"sync"
"github.com/joho/godotenv"
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
"github.com/rs/zerolog/log"
)
// Mu protects concurrent access to the configuration,
// particularly for fields updated by the config watcher (AuthUser, AuthPass, APITokens).
var Mu sync.RWMutex
const (
DefaultGuestMetadataMinRefresh = 2 * time.Minute
DefaultGuestMetadataRefreshJitter = 45 * time.Second
DefaultGuestMetadataRetryBackoff = 30 * time.Second
DefaultGuestMetadataMaxConcurrent = 4
)
// Vars for mocking system calls in tests
var (
osStat = os.Stat
execCommand = exec.Command
hashPasswordFn = auth.HashPassword
netDial = net.Dial
netInterfaceAddrs = net.InterfaceAddrs
)
// CanonicalUpdateChannel returns the supported v6 update channel if valid.
func CanonicalUpdateChannel(channel string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(channel)) {
case "stable":
return "stable", true
case "rc":
return "rc", true
default:
return "", false
}
}
// EffectiveUpdateChannel resolves the runtime update channel using persisted
// settings first and a fallback runtime/config value second. v6 only supports
// stable and rc, and defaults to stable when unset.
func EffectiveUpdateChannel(channel string, fallback string) string {
if canonical, ok := CanonicalUpdateChannel(channel); ok {
return canonical
}
if canonical, ok := CanonicalUpdateChannel(fallback); ok {
return canonical
}
return "stable"
}
// EffectiveAutoUpdateEnabled enforces the v6 policy that unattended
// auto-updates only run on the stable channel.
func EffectiveAutoUpdateEnabled(channel string, enabled bool) bool {
return enabled && EffectiveUpdateChannel(channel, "") == "stable"
}
// 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).
Msg("Bcrypt hash appears truncated; expected 60 characters; treating value as plaintext")
return false // Treat as plaintext to force user to fix it
}
return false
}
func normalizeEnvAuthPassword(raw string) (string, error) {
password := strings.Trim(raw, "'\"")
if password == "" {
return "", nil
}
if !IsPasswordHashed(password) {
hashedPass, err := hashPasswordFn(password)
if err != nil {
return "", fmt.Errorf("hash PULSE_AUTH_PASS: %w", err)
}
return hashedPass, nil
}
if len(password) != 60 {
log.Error().
Int("length", len(password)).
Str("env_var", "PULSE_AUTH_PASS").
Msg("Bcrypt hash appears truncated; expected 60 characters; authentication may fail")
log.Error().
Str("env_var", "PULSE_AUTH_PASS").
Msg("Ensure the full bcrypt hash is enclosed in single quotes in your .env file or Docker environment")
}
return password, nil
}
// 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
BindAddress string
FrontendPort int `envconfig:"FRONTEND_PORT" default:"7655"`
ConfigPath string
DataPath string
AppRoot string `json:"-"` // Root directory of the application (where binary lives)
PublicURL string `envconfig:"PULSE_PUBLIC_URL" default:""` // Full URL to access Pulse (e.g., http://198.51.100.100:7655)
AgentConnectURL string `envconfig:"PULSE_AGENT_CONNECT_URL" default:""` // Dedicated direct connect URL for agents (e.g. http://192.0.2.5:7655)
AgentIngestPort int `envconfig:"PULSE_AGENT_INGEST_PORT" default:"0"` // When >0, additionally serve agent ingest (/api/agents/*) on this dedicated port so it can be network-isolated from the UI/API. 0 = disabled (single-port, default).
// Proxmox VE connections
PVEInstances []PVEInstance
// Proxmox Backup Server connections
PBSInstances []PBSInstance
// Proxmox Mail Gateway connections
PMGInstances []PMGInstance
// Monitoring settings
PVEPollingInterval time.Duration `envconfig:"PVE_POLLING_INTERVAL"` // PVE polling interval (10s default)
PBSPollingInterval time.Duration `envconfig:"PBS_POLLING_INTERVAL"` // PBS polling interval (60s default)
PMGPollingInterval time.Duration `envconfig:"PMG_POLLING_INTERVAL"` // PMG polling interval (60s default)
ConnectionTimeout time.Duration `envconfig:"CONNECTION_TIMEOUT" default:"60s"` // Default 60s for slow storage operations
BackupPollingCycles int `envconfig:"BACKUP_POLLING_CYCLES" default:"10"`
BackupPollingInterval time.Duration `envconfig:"BACKUP_POLLING_INTERVAL"`
EnableBackupPolling bool `envconfig:"ENABLE_BACKUP_POLLING" default:"true"`
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
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"`
GuestMetadataMinRefreshInterval time.Duration `envconfig:"GUEST_METADATA_MIN_REFRESH_INTERVAL" default:"2m" json:"guestMetadataMinRefreshInterval"`
GuestMetadataRefreshJitter time.Duration `envconfig:"GUEST_METADATA_REFRESH_JITTER" default:"45s" json:"guestMetadataRefreshJitter"`
GuestMetadataRetryBackoff time.Duration `envconfig:"GUEST_METADATA_RETRY_BACKOFF" default:"30s" json:"guestMetadataRetryBackoff"`
GuestMetadataMaxConcurrent int `envconfig:"GUEST_METADATA_MAX_CONCURRENT" default:"4" json:"guestMetadataMaxConcurrent"`
DNSCacheTimeout time.Duration `envconfig:"DNS_CACHE_TIMEOUT" default:"5m" json:"dnsCacheTimeout"`
SSHPort int `envconfig:"SSH_PORT" default:"22" json:"sshPort"` // Default SSH port for temperature monitoring
MaxPollTimeout time.Duration `envconfig:"MAX_POLL_TIMEOUT" default:"3m" json:"-"` // Maximum poll timeout for large clusters (default 3m)
// Metrics retention settings (tiered storage)
// These control how long historical metrics are retained at each aggregation level.
MetricsRetentionRawHours int `json:"metricsRetentionRawHours"` // Raw data (~5s intervals), default: 2 hours
MetricsRetentionMinuteHours int `json:"metricsRetentionMinuteHours"` // Minute averages, default: 24 hours
MetricsRetentionHourlyDays int `json:"metricsRetentionHourlyDays"` // Hourly averages, default: 7 days
MetricsRetentionDailyDays int `json:"metricsRetentionDailyDays"` // Daily averages, default: 90 days
MetricsDBPath string `json:"-"` // Optional env-only SQLite path override for metrics.db
MetricsRollupInterval time.Duration `json:"-"` // Optional env-only rollup cadence override
// 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
APITokens []APITokenRecord `json:"-"`
AuthUser string `envconfig:"PULSE_AUTH_USER"`
AuthPass string `envconfig:"PULSE_AUTH_PASS"`
DemoMode bool `envconfig:"DEMO_MODE" default:"false"` // Read-only demo mode
AllowedOrigins string `envconfig:"ALLOWED_ORIGINS" default:"*"`
HideLocalLogin bool `envconfig:"PULSE_AUTH_HIDE_LOCAL_LOGIN" default:"false"`
DisableDockerUpdateActions bool `envconfig:"PULSE_DISABLE_DOCKER_UPDATE_ACTIONS" default:"false"` // Hide Docker update buttons (read-only mode for containers)
EnableProxmoxGuestDockerDetection bool `envconfig:"PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION" default:"false" json:"-"` // Allow Proxmox-side pct exec probes that only detect Docker socket presence inside LXC guests
EnableProxmoxGuestDockerInventory bool `envconfig:"PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY" default:"false" json:"-"` // Allow Proxmox-side pct exec collection of minimal Docker inventory from LXC guests
ProxmoxGuestDockerInventoryVMIDs string `envconfig:"PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS" default:"" json:"-"` // Optional comma-separated Proxmox VMID allowlist for LXC Docker inventory
TelemetryEnabled bool `envconfig:"PULSE_TELEMETRY" default:"true"` // Outbound usage telemetry enabled by default (pseudonymous install ID, version, resource counts, feature flags — opt out any time)
MultiTenantEnabled bool `envconfig:"PULSE_MULTI_TENANT_ENABLED" default:"false"` // Enable multi-tenant support
MetricsToken string `envconfig:"PULSE_METRICS_TOKEN" default:"" json:"-"` // Bearer token for /metrics endpoint (empty = unauthenticated)
MetricsBindAddress string `envconfig:"PULSE_METRICS_BIND_ADDRESS" default:"127.0.0.1" json:"-"` // Bind address for /metrics endpoint
MetricsAllowInsecureRemote bool `envconfig:"PULSE_METRICS_ALLOW_INSECURE_REMOTE" default:"false" json:"-"` // Explicit opt-in for bearer-token metrics over remote plaintext HTTP
ProTrialSignupURL string `envconfig:"PULSE_PRO_TRIAL_SIGNUP_URL" default:""` // Legacy hosted commercial base URL used for entitlement refresh compatibility
// 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"`
// HTTPS/TLS settings
HTTPSEnabled bool `envconfig:"HTTPS_ENABLED" default:"false"`
TLSCertFile string `envconfig:"TLS_CERT_FILE" default:""`
TLSKeyFile string `envconfig:"TLS_KEY_FILE" default:""`
HTTPRedirectPort int `envconfig:"HTTP_REDIRECT_PORT" default:"0"` // When HTTPS is enabled, start an HTTP listener on this port that redirects to HTTPS (0 = disabled)
// Update settings
UpdateChannel string
AutoUpdateEnabled bool
// Discovery settings
DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"false"`
DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"`
Discovery DiscoveryConfig `json:"discoveryConfig"`
// 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"`
IPBlocklist []string `json:"ip_blocklist,omitempty"` // Individual IPs to skip (auto-populated with configured Proxmox hosts)
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...)
}
if cfg.IPBlocklist != nil {
clone.IPBlocklist = append([]string(nil), cfg.IPBlocklist...)
}
return clone
}
// DeepCopy creates a deep copy of the Config struct.
// This is important for tenant isolation to ensure each tenant
// has independent config data that won't be shared.
func (c *Config) DeepCopy() *Config {
if c == nil {
return nil
}
// Start with a shallow copy
clone := *c
// Deep copy PVEInstances slice
if len(c.PVEInstances) > 0 {
clone.PVEInstances = clonePVEInstances(c.PVEInstances)
}
// Deep copy PBSInstances slice
if len(c.PBSInstances) > 0 {
clone.PBSInstances = make([]PBSInstance, len(c.PBSInstances))
copy(clone.PBSInstances, c.PBSInstances)
}
// Deep copy PMGInstances slice
if len(c.PMGInstances) > 0 {
clone.PMGInstances = make([]PMGInstance, len(c.PMGInstances))
copy(clone.PMGInstances, c.PMGInstances)
}
// Deep copy APITokens slice
if len(c.APITokens) > 0 {
clone.APITokens = make([]APITokenRecord, len(c.APITokens))
for i, token := range c.APITokens {
clone.APITokens[i] = token.Clone()
}
}
// Deep copy EnvOverrides map
if len(c.EnvOverrides) > 0 {
clone.EnvOverrides = make(map[string]bool, len(c.EnvOverrides))
for k, v := range c.EnvOverrides {
clone.EnvOverrides[k] = v
}
}
// Deep copy Discovery config
clone.Discovery = CloneDiscoveryConfig(c.Discovery)
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.
if canonicalEnv, ok := CanonicalDiscoveryEnvironment(normalized.EnvironmentOverride); ok {
normalized.EnvironmentOverride = canonicalEnv
} else {
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 decodes the persisted snake_case discovery config format.
func (d *DiscoveryConfig) UnmarshalJSON(data []byte) error {
type payload 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"`
}
var in payload
if err := json.Unmarshal(data, &in); err != nil {
return fmt.Errorf("unmarshal discovery config: %w", err)
}
cfg := DefaultDiscoveryConfig()
if in.EnvironmentOverride != nil {
cfg.EnvironmentOverride = strings.TrimSpace(*in.EnvironmentOverride)
}
switch {
case in.SubnetAllowlist != nil:
cfg.SubnetAllowlist = sanitizeCIDRList(*in.SubnetAllowlist)
default:
cfg.SubnetAllowlist = []string{}
}
switch {
case in.SubnetBlocklist != nil:
cfg.SubnetBlocklist = sanitizeCIDRList(*in.SubnetBlocklist)
}
if in.MaxHostsPerScan != nil {
cfg.MaxHostsPerScan = *in.MaxHostsPerScan
}
if in.MaxConcurrent != nil {
cfg.MaxConcurrent = *in.MaxConcurrent
}
if in.EnableReverseDNS != nil {
cfg.EnableReverseDNS = *in.EnableReverseDNS
}
if in.ScanGateways != nil {
cfg.ScanGateways = *in.ScanGateways
}
if in.DialTimeout != nil {
cfg.DialTimeout = *in.DialTimeout
}
if in.HTTPTimeout != nil {
cfg.HTTPTimeout = *in.HTTPTimeout
}
*d = NormalizeDiscoveryConfig(cfg)
return nil
}
// IsValidDiscoveryEnvironment reports whether the supplied override is recognised.
func IsValidDiscoveryEnvironment(value string) bool {
_, ok := CanonicalDiscoveryEnvironment(value)
return ok
}
// CanonicalDiscoveryEnvironment returns the canonical discovery environment token.
// Only canonical v6 hyphen-separated tokens are accepted.
func CanonicalDiscoveryEnvironment(value string) (string, bool) {
normalized := strings.ToLower(strings.TrimSpace(value))
switch normalized {
case "", "auto":
return "auto", true
case "native", "docker-host", "docker-bridge", "lxc-privileged", "lxc-unprivileged":
return normalized, 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
}
func parsePortOverride(envVar, value string) (int, error) {
port, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s must be an integer", envVar)
}
if port <= 0 || port > 65535 {
return 0, fmt.Errorf("%s must be between 1 and 65535", envVar)
}
return port, nil
}
func parseConnectionTimeoutOverride(value string) (time.Duration, error) {
raw := strings.TrimSpace(value)
if raw == "" {
return 0, fmt.Errorf("connection timeout value cannot be empty")
}
if looksLikeNumericSeconds(raw) {
return time.ParseDuration(raw + "s")
}
return time.ParseDuration(raw)
}
func looksLikeNumericSeconds(value string) bool {
if value == "" {
return false
}
if value[0] == '+' || value[0] == '-' {
value = value[1:]
if value == "" {
return false
}
}
dotSeen := false
for _, ch := range value {
if ch == '.' {
if dotSeen {
return false
}
dotSeen = true
continue
}
if ch < '0' || ch > '9' {
return false
}
}
return true
}
// PVEInstance represents a Proxmox VE connection
type PVEInstance struct {
Name string
Host string // Primary endpoint (user-provided)
GuestURL string // Optional guest-accessible URL (for navigation)
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)
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
SSHPort int // SSH port for temperature monitoring (0 = use global default)
// Cluster support
IsCluster bool // True if this is a cluster
ClusterName string // Cluster name if applicable
ClusterEndpoints []ClusterEndpoint // All discovered cluster nodes
ClusterNodeIdentities []PVEClusterNodeIdentity // Durable presentation identities for cluster members
// Agent tracking
Source string // "agent" or "script" - how this node was registered (empty = legacy/manual)
DisableCeph bool // Disable Ceph status polling for this instance
// Lifecycle. Disabled (not Enabled) so the zero value means "poll" —
// existing nodes.json records from before this field existed load
// unpaused without a schema migration.
Disabled bool `json:"disabled,omitempty"`
}
// ClusterEndpoint represents a single node in a cluster
type ClusterEndpoint struct {
NodeID string // Provider-reported cluster status ID (diagnostic only)
NodeIdentity string // Pulse-owned immutable identity scoped to this connection
NativeNodeID int // Provider-reported numeric node ID when available
NodeName string // Node name
Host string // Full URL (e.g., https://node1.lan:8006)
GuestURL string // Optional guest-accessible URL (for navigation)
IP string // IP address (auto-discovered from cluster)
IPOverride string // User-specified IP override (takes precedence over IP if set)
Fingerprint string // TLS certificate fingerprint (SHA256, auto-captured via TOFU)
Online bool // Current online status from Proxmox
LastSeen time.Time // Last successful connection
PulseReachable *bool // Pulse's view: can Pulse reach this endpoint? nil = not yet checked
LastPulseCheck *time.Time // Last time Pulse checked connectivity
PulseError string // Last error Pulse encountered connecting to this endpoint
}
// PVEClusterNodeIdentity keeps presentation metadata separate from endpoint
// routing. ID is created once per connection and remains stable across native
// node renames, address changes, and temporary cluster-membership removal.
type PVEClusterNodeIdentity struct {
ID string `json:"id"`
NativeNodeID int `json:"nativeNodeId,omitempty"`
NativeName string `json:"nativeName"`
NativeAliases []string `json:"nativeAliases,omitempty"`
DisplayName string `json:"displayName,omitempty"`
}
// EffectiveIP returns the IP to use for this endpoint, preferring IPOverride if set
func (e ClusterEndpoint) EffectiveIP() string {
if e.IPOverride != "" {
return e.IPOverride
}
return e.IP
}
// PBSInstance represents a Proxmox Backup Server connection
type PBSInstance struct {
Name string
Host string
GuestURL string // Optional guest-accessible URL (for navigation)
User string
Password string
TokenName string
TokenValue string
Fingerprint string
VerifySSL bool
MonitorBackups bool
MonitorDatastores bool
MonitorSyncJobs bool
MonitorVerifyJobs bool
MonitorPruneJobs bool
MonitorGarbageJobs bool
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
SSHPort int // SSH port for temperature monitoring (0 = use global default)
// Agent tracking
Source string // "agent" or "script" - how this node was registered (empty = legacy/manual)
DisableCeph bool // Disable Ceph status polling for this instance
// Datastore exclusion (for unmounted/removable datastores that cause log noise)
ExcludeDatastores []string
// Lifecycle. See PVEInstance.Disabled.
Disabled bool `json:"disabled,omitempty"`
}
// PMGInstance represents a Proxmox Mail Gateway connection
type PMGInstance struct {
Name string
Host string
GuestURL string // Optional guest-accessible URL (for navigation)
User string
Password string
TokenName string
TokenValue string
Fingerprint string
VerifySSL bool
MonitorMailStats bool
MonitorQueues bool
MonitorQuarantine bool
MonitorDomainStats bool
TemperatureMonitoringEnabled *bool // Monitor temperature via SSH (nil = use global setting, true/false = override)
SSHPort int // SSH port for temperature monitoring (0 = use global default)
// Lifecycle. See PVEInstance.Disabled.
Disabled bool `json:"disabled,omitempty"`
}
// Global persistence instance for saving
var globalPersistence *ConfigPersistence
// defaultDataDir is the fallback config/data directory when PULSE_DATA_DIR is not set.
// Kept as a var to allow tests to override without requiring /etc/pulse to exist.
var defaultDataDir = "/etc/pulse"
// ResolveRuntimeDataDir returns the canonical runtime data directory for Pulse.
// Callers may provide an explicit owner path; otherwise the resolved directory
// comes from PULSE_DATA_DIR and finally the package default.
func ResolveRuntimeDataDir(explicitPath string) string {
return utils.ResolveDataDirWithDefault(explicitPath, defaultDataDir)
}
// parsePollingIntervalEnv reads an environment variable that represents a polling
// interval (accepts Go duration strings like "30s" or plain integer seconds),
// validates that it meets a minimum, and returns the parsed duration. Returns
// (0, false) when the env var is unset or the value is invalid/below minimum.
func parsePollingIntervalEnv(envName string, minDuration time.Duration) (time.Duration, bool) {
intervalStr := utils.GetenvTrim(envName)
if intervalStr == "" {
return 0, false
}
if dur, err := time.ParseDuration(intervalStr); err == nil {
if dur < minDuration {
log.Warn().Dur("interval", dur).Msgf("Ignoring %s below %s from environment", envName, minDuration)
return 0, false
}
log.Info().Dur("interval", dur).Msgf("Overriding %s polling interval from environment", strings.TrimSuffix(envName, "_POLLING_INTERVAL"))
return dur, true
}
if seconds, err := strconv.Atoi(intervalStr); err == nil {
if time.Duration(seconds)*time.Second < minDuration {
log.Warn().Int("seconds", seconds).Msgf("Ignoring %s below %s from environment", envName, minDuration)
return 0, false
}
log.Info().Int("seconds", seconds).Msgf("Overriding %s polling interval (seconds) from environment", strings.TrimSuffix(envName, "_POLLING_INTERVAL"))
return time.Duration(seconds) * time.Second, true
}
log.Warn().Str("value", intervalStr).Msgf("Invalid %s value, expected duration or seconds", envName)
return 0, false
}
func parseDurationOverrideEnv(envName string, minDuration time.Duration) (time.Duration, bool) {
value := utils.GetenvTrim(envName)
if value == "" {
return 0, false
}
if dur, err := time.ParseDuration(value); err == nil {
if dur < minDuration {
log.Warn().Dur("duration", dur).Msgf("Ignoring %s below %s from environment", envName, minDuration)
return 0, false
}
return dur, true
}
if seconds, err := strconv.Atoi(value); err == nil {
dur := time.Duration(seconds) * time.Second
if dur < minDuration {
log.Warn().Int("seconds", seconds).Msgf("Ignoring %s below %s from environment", envName, minDuration)
return 0, false
}
return dur, true
}
log.Warn().Str("value", value).Msgf("Invalid %s value, expected duration or seconds", envName)
return 0, false
}
// Load reads configuration from encrypted persistence files.
func Load() (*Config, error) {
return load(true)
}
// LoadWithoutLoggingInit reads configuration without reinitializing the global logger.
// Reload paths use this to avoid racing with long-lived background goroutines that are
// still emitting through zerolog/log while the new config is being loaded.
func LoadWithoutLoggingInit() (*Config, error) {
return load(false)
}
func load(initLogging bool) (*Config, error) {
// Get data directory from environment
dataDir := ResolveRuntimeDataDir("")
// 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{
BindAddress: "0.0.0.0",
FrontendPort: 7655,
ConfigPath: dataDir,
DataPath: dataDir,
AppRoot: detectAppRoot(),
ConnectionTimeout: 60 * time.Second,
BackupPollingCycles: 10,
BackupPollingInterval: 0,
EnableBackupPolling: true,
PVEPollingInterval: 10 * time.Second,
AdaptivePollingEnabled: false,
AdaptivePollingBaseInterval: 10 * time.Second,
AdaptivePollingMinInterval: 5 * time.Second,
AdaptivePollingMaxInterval: 5 * time.Minute,
GuestMetadataMinRefreshInterval: DefaultGuestMetadataMinRefresh,
GuestMetadataRefreshJitter: DefaultGuestMetadataRefreshJitter,
GuestMetadataRetryBackoff: DefaultGuestMetadataRetryBackoff,
GuestMetadataMaxConcurrent: DefaultGuestMetadataMaxConcurrent,
DNSCacheTimeout: 5 * time.Minute, // Default DNS cache timeout
LogLevel: "info",
LogFormat: "auto",
LogMaxSize: 100,
LogMaxAge: 30,
LogCompress: true,
AllowedOrigins: "", // Empty means no CORS headers (same-origin only)
PBSPollingInterval: 60 * time.Second, // Default PBS polling (slower)
PMGPollingInterval: 60 * time.Second, // Default PMG polling (aggregated stats)
DiscoveryEnabled: false,
DiscoverySubnet: "auto",
TemperatureMonitoringEnabled: true,
TelemetryEnabled: true, // Enabled by default; opt out via PULSE_TELEMETRY=false or Settings
MaxPollTimeout: 3 * time.Minute, // Default max poll timeout for large clusters
ProTrialSignupURL: pkglicensing.DefaultProTrialSignupURL,
EnvOverrides: make(map[string]bool),
AgentConnectURL: "",
MetricsBindAddress: "127.0.0.1",
// Metrics retention defaults (tiered)
MetricsRetentionRawHours: 2, // 2 hours of raw ~5s data
MetricsRetentionMinuteHours: 24, // 24 hours of minute averages
MetricsRetentionHourlyDays: 7, // 7 days of hourly averages
MetricsRetentionDailyDays: 90, // 90 days of daily averages
}
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 polling intervals if configured
if systemSettings.PVEPollingInterval > 0 {
cfg.PVEPollingInterval = time.Duration(systemSettings.PVEPollingInterval) * time.Second
}
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
}
cfg.UpdateChannel = EffectiveUpdateChannel(systemSettings.UpdateChannel, cfg.UpdateChannel)
cfg.AutoUpdateEnabled = EffectiveAutoUpdateEnabled(cfg.UpdateChannel, systemSettings.AutoUpdateEnabled)
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))
cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled
// Load DNS cache timeout
if systemSettings.DNSCacheTimeout > 0 {
cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second
}
// Load SSH port
if systemSettings.SSHPort > 0 {
cfg.SSHPort = systemSettings.SSHPort
} else {
cfg.SSHPort = 22 // Default SSH port
}
// Load HideLocalLogin
cfg.HideLocalLogin = systemSettings.HideLocalLogin
// Load DisableDockerUpdateActions (hide Docker update buttons)
cfg.DisableDockerUpdateActions = systemSettings.DisableDockerUpdateActions
// Load TelemetryEnabled (enabled by default; nil means true for upgrading users)
if systemSettings.TelemetryEnabled != nil {
cfg.TelemetryEnabled = *systemSettings.TelemetryEnabled
} else {
cfg.TelemetryEnabled = true // default: enabled
}
// Load PublicURL from settings (will be overridden by env var if set)
if systemSettings.PublicURL != "" {
cfg.PublicURL = systemSettings.PublicURL
}
// Load metrics retention settings (only override if explicitly set)
if systemSettings.MetricsRetentionRawHours > 0 {
cfg.MetricsRetentionRawHours = systemSettings.MetricsRetentionRawHours
}
if systemSettings.MetricsRetentionMinuteHours > 0 {
cfg.MetricsRetentionMinuteHours = systemSettings.MetricsRetentionMinuteHours
}
if systemSettings.MetricsRetentionHourlyDays > 0 {
cfg.MetricsRetentionHourlyDays = systemSettings.MetricsRetentionHourlyDays
}
if systemSettings.MetricsRetentionDailyDays > 0 {
cfg.MetricsRetentionDailyDays = systemSettings.MetricsRetentionDailyDays
}
// APIToken is not loaded from system.json; it is kept in sync from APITokens.
log.Info().
Str("updateChannel", cfg.UpdateChannel).
Str("logLevel", cfg.LogLevel).
Dur("dnsCacheTimeout", cfg.DNSCacheTimeout).
Int("metricsRetentionDailyDays", cfg.MetricsRetentionDailyDays).
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")
}
}
}
// Load API tokens
if tokens, err := persistence.LoadAPITokens(); err == nil {
if migrated := bindMissingAPITokenIDs(tokens); migrated > 0 {
if err := persistence.SaveAPITokens(tokens); err != nil {
log.Error().Err(err).Int("count", migrated).Msg("Failed to persist API token ID migration")
} else {
log.Warn().Int("count", migrated).Msg("Migrated API tokens missing IDs")
}
}
if migrated := bindLegacyAPITokensToDefault(tokens); migrated > 0 {
if err := persistence.SaveAPITokens(tokens); err != nil {
log.Error().Err(err).Int("count", migrated).Msg("Failed to persist legacy API token org binding migration")
} else {
log.Warn().Int("count", migrated).Msg("Migrated legacy API tokens to default organization binding")
}
}
cfg.APITokens = tokens
cfg.SortAPITokens()
log.Info().Int("count", len(tokens)).Msg("Loaded API tokens from persistence")
} else {
log.Warn().Err(err).Msg("Failed to load API tokens from persistence")
}
// Ensure polling intervals have sane defaults if not set
if cfg.PVEPollingInterval <= 0 {
cfg.PVEPollingInterval = 10 * time.Second
}
if cfg.PVEPollingInterval > time.Hour {
cfg.PVEPollingInterval = time.Hour
}
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 := utils.GetenvTrim("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 := utils.GetenvTrim("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 dur, ok := parsePollingIntervalEnv("PVE_POLLING_INTERVAL", 10*time.Second); ok {
cfg.PVEPollingInterval = dur
cfg.EnvOverrides["PVE_POLLING_INTERVAL"] = true
}
if dur, ok := parsePollingIntervalEnv("PBS_POLLING_INTERVAL", 10*time.Second); ok {
cfg.PBSPollingInterval = dur
cfg.EnvOverrides["PBS_POLLING_INTERVAL"] = true
}
if dur, ok := parsePollingIntervalEnv("PMG_POLLING_INTERVAL", 10*time.Second); ok {
cfg.PMGPollingInterval = dur
cfg.EnvOverrides["PMG_POLLING_INTERVAL"] = true
}
if enabledStr := utils.GetenvTrim("ENABLE_TEMPERATURE_MONITORING"); enabledStr != "" {
if enabled, err := strconv.ParseBool(enabledStr); err == nil {
cfg.TemperatureMonitoringEnabled = enabled
cfg.EnvOverrides["temperatureMonitoringEnabled"] = true
log.Info().
Bool("enabled", enabled).
Msg("Overriding temperature monitoring setting from environment")
} else {
log.Warn().
Str("value", enabledStr).
Msg("Invalid ENABLE_TEMPERATURE_MONITORING value, ignoring")
}
}
if hideLocalLoginStr := utils.GetenvTrim("PULSE_AUTH_HIDE_LOCAL_LOGIN"); hideLocalLoginStr != "" {
if hide, err := strconv.ParseBool(hideLocalLoginStr); err == nil {
cfg.HideLocalLogin = hide
cfg.EnvOverrides["PULSE_AUTH_HIDE_LOCAL_LOGIN"] = true
log.Info().Bool("hide", hide).Msg("Overriding hide local login setting from environment")
} else {
log.Warn().Str("value", hideLocalLoginStr).Msg("Invalid PULSE_AUTH_HIDE_LOCAL_LOGIN value, ignoring")
}
}
if disableDockerUpdateActionsStr := utils.GetenvTrim("PULSE_DISABLE_DOCKER_UPDATE_ACTIONS"); disableDockerUpdateActionsStr != "" {
if disabled, err := strconv.ParseBool(disableDockerUpdateActionsStr); err == nil {
cfg.DisableDockerUpdateActions = disabled
cfg.EnvOverrides["PULSE_DISABLE_DOCKER_UPDATE_ACTIONS"] = true
cfg.EnvOverrides["disableDockerUpdateActions"] = true
log.Info().Bool("disabled", disabled).Msg("Overriding Docker update actions setting from environment")
} else {
log.Warn().
Str("value", disableDockerUpdateActionsStr).
Msg("Invalid PULSE_DISABLE_DOCKER_UPDATE_ACTIONS value, ignoring")
}
}
if guestDockerDetectionStr := utils.GetenvTrim("PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION"); guestDockerDetectionStr != "" {
if enabled, err := strconv.ParseBool(guestDockerDetectionStr); err == nil {
cfg.EnableProxmoxGuestDockerDetection = enabled
cfg.EnvOverrides["PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION"] = true
log.Info().Bool("enabled", enabled).Msg("Overriding Proxmox guest Docker detection setting from environment")
} else {
log.Warn().
Str("value", guestDockerDetectionStr).
Msg("Invalid PULSE_ENABLE_PROXMOX_GUEST_DOCKER_DETECTION value, ignoring")
}
}
if guestDockerInventoryStr := utils.GetenvTrim("PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY"); guestDockerInventoryStr != "" {
if enabled, err := strconv.ParseBool(guestDockerInventoryStr); err == nil {
cfg.EnableProxmoxGuestDockerInventory = enabled
cfg.EnvOverrides["PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY"] = true
log.Info().Bool("enabled", enabled).Msg("Overriding Proxmox guest Docker inventory setting from environment")
} else {
log.Warn().
Str("value", guestDockerInventoryStr).
Msg("Invalid PULSE_ENABLE_PROXMOX_GUEST_DOCKER_INVENTORY value, ignoring")
}
}
if guestDockerInventoryVMIDs := utils.GetenvTrim("PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS"); guestDockerInventoryVMIDs != "" {
cfg.ProxmoxGuestDockerInventoryVMIDs = guestDockerInventoryVMIDs
cfg.EnvOverrides["PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS"] = true
log.Info().Str("vmids", guestDockerInventoryVMIDs).Msg("Overriding Proxmox guest Docker inventory VMID allowlist from environment")
}
if telemetryStr := utils.GetenvTrim("PULSE_TELEMETRY"); telemetryStr != "" {
if enabled, err := strconv.ParseBool(telemetryStr); err == nil {
cfg.TelemetryEnabled = enabled
cfg.EnvOverrides["PULSE_TELEMETRY"] = true
cfg.EnvOverrides["telemetryEnabled"] = true
log.Info().Bool("enabled", enabled).Msg("Overriding outbound usage telemetry setting from environment")
} else {
log.Warn().Str("value", telemetryStr).Msg("Invalid PULSE_TELEMETRY value, ignoring")
}
}
if trialSignupURL := utils.GetenvTrim(pkglicensing.ProTrialSignupURLEnvVar); trialSignupURL != "" {
resolved := pkglicensing.ResolveProTrialSignupURL(trialSignupURL)
if resolved != pkglicensing.DefaultProTrialSignupURL {
cfg.ProTrialSignupURL = resolved
cfg.EnvOverrides[pkglicensing.ProTrialSignupURLEnvVar] = true
cfg.EnvOverrides["proTrialSignupURL"] = true
log.Info().Str("url", resolved).Msg("Overriding hosted commercial base URL from environment")
} else {
log.Warn().Str("value", trialSignupURL).Msg("Invalid PULSE_PRO_TRIAL_SIGNUP_URL value, ignoring")
}
}
if enabledStr := utils.GetenvTrim("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 := utils.GetenvTrim("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 := utils.GetenvTrim("ADAPTIVE_POLLING_BASE_INTERVAL"); baseInterval != "" {
if dur, err := time.ParseDuration(baseInterval); err == nil {
if dur <= 0 {
log.Warn().Str("value", baseInterval).Msg("Ignoring non-positive ADAPTIVE_POLLING_BASE_INTERVAL from environment")
} else {
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 := utils.GetenvTrim("ADAPTIVE_POLLING_MIN_INTERVAL"); minInterval != "" {
if dur, err := time.ParseDuration(minInterval); err == nil {
if dur <= 0 {
log.Warn().Str("value", minInterval).Msg("Ignoring non-positive ADAPTIVE_POLLING_MIN_INTERVAL from environment")
} else {
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 := utils.GetenvTrim("ADAPTIVE_POLLING_MAX_INTERVAL"); maxInterval != "" {
if dur, err := time.ParseDuration(maxInterval); err == nil {
if dur <= 0 {
log.Warn().Str("value", maxInterval).Msg("Ignoring non-positive ADAPTIVE_POLLING_MAX_INTERVAL from environment")
} else {
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")
}
}
if minRefresh := utils.GetenvTrim("GUEST_METADATA_MIN_REFRESH_INTERVAL"); minRefresh != "" {
if dur, err := time.ParseDuration(minRefresh); err == nil {
if dur <= 0 {
log.Warn().Str("value", minRefresh).Msg("Ignoring non-positive GUEST_METADATA_MIN_REFRESH_INTERVAL from environment")
} else {
cfg.GuestMetadataMinRefreshInterval = dur
cfg.EnvOverrides["GUEST_METADATA_MIN_REFRESH_INTERVAL"] = true
log.Info().Dur("interval", dur).Msg("Guest metadata min refresh interval overridden by environment")
}
} else {
log.Warn().Str("value", minRefresh).Msg("Invalid GUEST_METADATA_MIN_REFRESH_INTERVAL value, expected duration string")
}
}
if jitter := utils.GetenvTrim("GUEST_METADATA_REFRESH_JITTER"); jitter != "" {
if dur, err := time.ParseDuration(jitter); err == nil {
if dur < 0 {
log.Warn().Str("value", jitter).Msg("Ignoring negative GUEST_METADATA_REFRESH_JITTER from environment")
} else {
cfg.GuestMetadataRefreshJitter = dur
cfg.EnvOverrides["GUEST_METADATA_REFRESH_JITTER"] = true
log.Info().Dur("jitter", dur).Msg("Guest metadata refresh jitter overridden by environment")
}
} else {
log.Warn().Str("value", jitter).Msg("Invalid GUEST_METADATA_REFRESH_JITTER value, expected duration string")
}
}
if backoff := utils.GetenvTrim("GUEST_METADATA_RETRY_BACKOFF"); backoff != "" {
if dur, err := time.ParseDuration(backoff); err == nil {
if dur <= 0 {
log.Warn().Str("value", backoff).Msg("Ignoring non-positive GUEST_METADATA_RETRY_BACKOFF from environment")
} else {
cfg.GuestMetadataRetryBackoff = dur
cfg.EnvOverrides["GUEST_METADATA_RETRY_BACKOFF"] = true
log.Info().Dur("backoff", dur).Msg("Guest metadata retry backoff overridden by environment")
}
} else {
log.Warn().Str("value", backoff).Msg("Invalid GUEST_METADATA_RETRY_BACKOFF value, expected duration string")
}
}
if concurrent := utils.GetenvTrim("GUEST_METADATA_MAX_CONCURRENT"); concurrent != "" {
if val, err := strconv.Atoi(concurrent); err == nil {
if val <= 0 {
log.Warn().Str("value", concurrent).Msg("Ignoring non-positive GUEST_METADATA_MAX_CONCURRENT from environment")
} else {
cfg.GuestMetadataMaxConcurrent = val
cfg.EnvOverrides["GUEST_METADATA_MAX_CONCURRENT"] = true
log.Info().Int("maxConcurrent", val).Msg("Guest metadata max concurrency overridden by environment")
}
} else {
log.Warn().Str("value", concurrent).Msg("Invalid GUEST_METADATA_MAX_CONCURRENT value, expected integer")
}
}
if dnsCacheTimeout := utils.GetenvTrim("DNS_CACHE_TIMEOUT"); dnsCacheTimeout != "" {
if dur, err := time.ParseDuration(dnsCacheTimeout); err == nil {
if dur <= 0 {
log.Warn().Str("value", dnsCacheTimeout).Msg("Ignoring non-positive DNS_CACHE_TIMEOUT from environment")
} else {
cfg.DNSCacheTimeout = dur
cfg.EnvOverrides["DNS_CACHE_TIMEOUT"] = true
log.Info().Dur("timeout", dur).Msg("DNS cache timeout overridden by environment")
}
} else {
log.Warn().Str("value", dnsCacheTimeout).Msg("Invalid DNS_CACHE_TIMEOUT value, expected duration string")
}
}
// HTTPS Configuration
if httpsEnabled := utils.GetenvTrim("HTTPS_ENABLED"); httpsEnabled != "" {
if enabled, err := strconv.ParseBool(httpsEnabled); err == nil {
cfg.HTTPSEnabled = enabled
cfg.EnvOverrides["HTTPS_ENABLED"] = true
log.Info().Bool("enabled", enabled).Msg("HTTPS enabled override from environment")
} else {
log.Warn().Str("value", httpsEnabled).Msg("Invalid HTTPS_ENABLED value, ignoring")
}
}
if certFile := utils.GetenvTrim("TLS_CERT_FILE"); certFile != "" {
cfg.TLSCertFile = certFile
cfg.EnvOverrides["TLS_CERT_FILE"] = true
}
if keyFile := utils.GetenvTrim("TLS_KEY_FILE"); keyFile != "" {
cfg.TLSKeyFile = keyFile
cfg.EnvOverrides["TLS_KEY_FILE"] = true
}
if allowedOrigins := utils.GetenvTrim("ALLOWED_ORIGINS"); allowedOrigins != "" {
cfg.AllowedOrigins = allowedOrigins
cfg.EnvOverrides["ALLOWED_ORIGINS"] = true
}
if bindAddr := utils.GetenvTrim("BIND_ADDRESS"); bindAddr != "" {
cfg.BindAddress = bindAddr
cfg.EnvOverrides["BIND_ADDRESS"] = true
}
if sshPort := utils.GetenvTrim("SSH_PORT"); sshPort != "" {
if port, err := strconv.Atoi(sshPort); err == nil {
if port <= 0 || port > 65535 {
log.Warn().Str("value", sshPort).Msg("Ignoring invalid SSH_PORT from environment (must be 1-65535)")
} else {
cfg.SSHPort = port
cfg.EnvOverrides["SSH_PORT"] = true
log.Info().Int("port", port).Msg("SSH port overridden by environment")
}
} else {
log.Warn().Str("value", sshPort).Msg("Invalid SSH_PORT value, expected integer")
}
}
if frontendPort := utils.GetenvTrim("FRONTEND_PORT"); frontendPort != "" {
if p, err := parsePortOverride("FRONTEND_PORT", frontendPort); err == nil {
cfg.FrontendPort = p
cfg.EnvOverrides["FRONTEND_PORT"] = true
log.Info().Int("port", p).Msg("Overriding frontend port from FRONTEND_PORT env var")
} else {
log.Warn().Str("value", frontendPort).Msg("Ignoring invalid FRONTEND_PORT from environment")
}
} else if legacyPort := utils.GetenvTrim("PORT"); legacyPort != "" {
// v5 honored PORT as a documented alias for FRONTEND_PORT; keep it as a
// lowest-precedence fallback so upgraded installs stay reachable.
if p, err := parsePortOverride("PORT", legacyPort); err == nil {
cfg.FrontendPort = p
cfg.EnvOverrides["FRONTEND_PORT"] = true
log.Warn().Int("port", p).Msg("Overriding frontend port from legacy PORT env var. PORT is deprecated; set FRONTEND_PORT instead")
} else {
log.Warn().Str("value", legacyPort).Msg("Ignoring invalid legacy PORT from environment")
}
}
if agentIngestPort := utils.GetenvTrim("PULSE_AGENT_INGEST_PORT"); agentIngestPort != "" {
if p, err := parsePortOverride("PULSE_AGENT_INGEST_PORT", agentIngestPort); err == nil {
cfg.AgentIngestPort = p
cfg.EnvOverrides["PULSE_AGENT_INGEST_PORT"] = true
log.Info().Int("port", p).Msg("Serving agent ingest on dedicated port from PULSE_AGENT_INGEST_PORT env var")
} else {
log.Warn().Str("value", agentIngestPort).Msg("Ignoring invalid PULSE_AGENT_INGEST_PORT from environment")
}
}
// Detect deprecated DISABLE_AUTH flag and strip it from the runtime env so downstream
// components behave as if it were never set.
if disableAuthEnv := os.Getenv("DISABLE_AUTH"); disableAuthEnv != "" {
if err := os.Unsetenv("DISABLE_AUTH"); err != nil {
log.Warn().
Str("DISABLE_AUTH", disableAuthEnv).
Err(err).
Msg("Failed to remove legacy DISABLE_AUTH environment variable; continuing with authentication enabled")
} else {
log.Warn().
Str("DISABLE_AUTH", disableAuthEnv).
Msg("Removed legacy DISABLE_AUTH environment variable. Authentication remains enabled.")
}
}
// 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")
}
}
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 != "" {
normalizedAuthPass, err := normalizeEnvAuthPassword(authPass)
if err != nil {
return nil, err
}
cfg.AuthPass = normalizedAuthPass
if !IsPasswordHashed(strings.Trim(authPass, "'\"")) {
log.Info().Msg("Auto-hashed plain text password from environment variable")
} else {
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")
}
if redirectPort := os.Getenv("HTTP_REDIRECT_PORT"); redirectPort != "" {
if p, err := strconv.Atoi(redirectPort); err == nil && p > 0 && p <= 65535 {
cfg.HTTPRedirectPort = p
cfg.EnvOverrides["HTTP_REDIRECT_PORT"] = true
log.Debug().Int("port", p).Msg("HTTP redirect port from env var")
}
}
if metricsToken := utils.GetenvTrim("PULSE_METRICS_TOKEN"); metricsToken != "" {
cfg.MetricsToken = metricsToken
cfg.EnvOverrides["PULSE_METRICS_TOKEN"] = true
log.Debug().Msg("Metrics token configured from env var")
}
if metricsBindAddress := utils.GetenvTrim("PULSE_METRICS_BIND_ADDRESS"); metricsBindAddress != "" {
cfg.MetricsBindAddress = metricsBindAddress
cfg.EnvOverrides["PULSE_METRICS_BIND_ADDRESS"] = true
log.Info().Str("bind_address", metricsBindAddress).Msg("Metrics bind address overridden by environment")
}
if allowInsecureRemote := utils.GetenvTrim("PULSE_METRICS_ALLOW_INSECURE_REMOTE"); allowInsecureRemote != "" {
if enabled, err := strconv.ParseBool(allowInsecureRemote); err == nil {
cfg.MetricsAllowInsecureRemote = enabled
cfg.EnvOverrides["PULSE_METRICS_ALLOW_INSECURE_REMOTE"] = true
log.Warn().Bool("enabled", enabled).Msg("Metrics insecure remote scrape override configured")
} else {
log.Warn().Str("value", allowInsecureRemote).Msg("Invalid PULSE_METRICS_ALLOW_INSECURE_REMOTE value, ignoring")
}
}
if metricsDBPath := utils.GetenvTrim("PULSE_METRICS_DB_PATH"); metricsDBPath != "" {
cfg.MetricsDBPath = metricsDBPath
cfg.EnvOverrides["PULSE_METRICS_DB_PATH"] = true
log.Info().Str("path", metricsDBPath).Msg("Metrics database path overridden by environment")
}
if dur, ok := parseDurationOverrideEnv("PULSE_METRICS_ROLLUP_INTERVAL", 5*time.Minute); ok {
cfg.MetricsRollupInterval = dur
cfg.EnvOverrides["PULSE_METRICS_ROLLUP_INTERVAL"] = true
log.Info().Dur("interval", dur).Msg("Metrics rollup interval overridden by environment")
}
// Support PULSE_AGENT_URL as an alias for PULSE_AGENT_CONNECT_URL
// (Check this first so standard PULSE_AGENT_CONNECT_URL takes precedence if both set)
if agentURL := utils.GetenvTrim("PULSE_AGENT_URL"); agentURL != "" {
cfg.AgentConnectURL = agentURL
cfg.EnvOverrides["PULSE_AGENT_CONNECT_URL"] = true
log.Info().Str("url", agentURL).Msg("Using dedicated agent connect URL from PULSE_AGENT_URL")
}
if agentConnectURL := utils.GetenvTrim("PULSE_AGENT_CONNECT_URL"); agentConnectURL != "" {
cfg.AgentConnectURL = agentConnectURL
cfg.EnvOverrides["PULSE_AGENT_CONNECT_URL"] = true
log.Info().Str("url", agentConnectURL).Msg("Using dedicated agent connect URL from environment")
}
// Update settings are system.json-only; legacy env vars remain ignored.
// 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 := utils.GetenvTrim("DISCOVERY_ENVIRONMENT_OVERRIDE"); envOverride != "" {
if canonicalEnv, ok := CanonicalDiscoveryEnvironment(envOverride); ok {
cfg.Discovery.EnvironmentOverride = canonicalEnv
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 := utils.GetenvTrim("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 := utils.GetenvTrim("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 := utils.GetenvTrim("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 := utils.GetenvTrim("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 := utils.GetenvTrim("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 := utils.GetenvTrim("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 := utils.GetenvTrim("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 := utils.GetenvTrim("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")
}
if logFile := os.Getenv("LOG_FILE"); logFile != "" {
cfg.LogFile = logFile
cfg.EnvOverrides["logFile"] = true
log.Info().Str("file", logFile).Msg("Log file overridden by LOG_FILE env var")
}
if logMaxSize := os.Getenv("LOG_MAX_SIZE"); logMaxSize != "" {
if size, err := strconv.Atoi(logMaxSize); err == nil {
if size <= 0 {
log.Warn().Str("value", logMaxSize).Msg("Ignoring non-positive LOG_MAX_SIZE from environment")
} else {
cfg.LogMaxSize = size
cfg.EnvOverrides["logMaxSize"] = true
log.Info().Int("size_mb", size).Msg("Log max size overridden by LOG_MAX_SIZE env var")
}
} else {
log.Warn().Str("value", logMaxSize).Msg("Invalid LOG_MAX_SIZE value, expected integer")
}
}
if logMaxAge := os.Getenv("LOG_MAX_AGE"); logMaxAge != "" {
if age, err := strconv.Atoi(logMaxAge); err == nil {
if age <= 0 {
log.Warn().Str("value", logMaxAge).Msg("Ignoring non-positive LOG_MAX_AGE from environment")
} else {
cfg.LogMaxAge = age
cfg.EnvOverrides["logMaxAge"] = true
log.Info().Int("days", age).Msg("Log max age overridden by LOG_MAX_AGE env var")
}
} else {
log.Warn().Str("value", logMaxAge).Msg("Invalid LOG_MAX_AGE value, expected integer")
}
}
if logCompress := os.Getenv("LOG_COMPRESS"); logCompress != "" {
cfg.LogCompress = logCompress == "true" || logCompress == "1"
cfg.EnvOverrides["logCompress"] = true
log.Info().Bool("compress", cfg.LogCompress).Msg("Log compression overridden by LOG_COMPRESS env var")
}
cfg.Discovery = NormalizeDiscoveryConfig(cfg.Discovery)
if connectionTimeout := utils.GetenvTrim("CONNECTION_TIMEOUT"); connectionTimeout != "" {
if d, err := parseConnectionTimeoutOverride(connectionTimeout); err == nil {
if d < time.Second {
log.Warn().Dur("value", d).Msg("Ignoring CONNECTION_TIMEOUT below 1s from environment")
} else {
cfg.ConnectionTimeout = d
cfg.EnvOverrides["connectionTimeout"] = true
log.Info().Dur("timeout", d).Msg("Connection timeout overridden by CONNECTION_TIMEOUT env var")
}
} else {
log.Warn().Str("value", connectionTimeout).Msg("Invalid CONNECTION_TIMEOUT value, expected seconds or duration string")
}
}
if maxPollTimeout := os.Getenv("MAX_POLL_TIMEOUT"); maxPollTimeout != "" {
if d, err := time.ParseDuration(maxPollTimeout); err == nil {
if d >= 30*time.Second { // Minimum 30 seconds
cfg.MaxPollTimeout = d
cfg.EnvOverrides["maxPollTimeout"] = true
log.Info().Dur("timeout", d).Msg("Max poll timeout overridden by MAX_POLL_TIMEOUT env var")
} else {
log.Warn().Dur("value", d).Msg("MAX_POLL_TIMEOUT too low (minimum 30s), using default")
}
} else {
log.Warn().Str("value", maxPollTimeout).Msg("Invalid MAX_POLL_TIMEOUT value, using default")
}
}
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 {
// In hosted mode, fail closed unless explicitly configured.
if os.Getenv("PULSE_HOSTED_MODE") == "true" {
log.Warn().Msg("Hosted mode enabled: public URL not configured; external links (e.g., magic links) will be disabled. Set PULSE_PUBLIC_URL.")
} 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")
}
}
}
if initLogging {
// Initialize logging with configuration values
logging.Init(logging.Config{
Format: cfg.LogFormat,
Level: cfg.LogLevel,
Component: "pulse-config",
FilePath: cfg.LogFile,
MaxSizeMB: cfg.LogMaxSize,
MaxAgeDays: cfg.LogMaxAge,
Compress: cfg.LogCompress,
})
}
// Initialize DNS cache with configured timeout
// This must be done before any HTTP clients are created
tlsutil.SetDNSCacheTTL(cfg.DNSCacheTimeout)
// Validate configuration
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("load config: invalid configuration: %w", err)
}
return cfg, nil
}
// Validate checks if the configuration is valid
func (c *Config) Validate() error {
// Validate server settings
if c.FrontendPort <= 0 || c.FrontendPort > 65535 {
return fmt.Errorf("invalid frontend port: %d", c.FrontendPort)
}
if c.SSHPort != 0 && (c.SSHPort < 1 || c.SSHPort > 65535) {
return fmt.Errorf("invalid SSH port: %d (must be between 1 and 65535)", c.SSHPort)
}
if c.HTTPRedirectPort != 0 {
if c.HTTPRedirectPort < 1 || c.HTTPRedirectPort > 65535 {
return fmt.Errorf("invalid HTTP redirect port: %d (must be between 1 and 65535)", c.HTTPRedirectPort)
}
if c.HTTPRedirectPort == c.FrontendPort {
return fmt.Errorf("HTTP redirect port (%d) must differ from frontend port", c.HTTPRedirectPort)
}
}
if c.AgentIngestPort != 0 {
if c.AgentIngestPort < 1 || c.AgentIngestPort > 65535 {
return fmt.Errorf("invalid agent ingest port: %d (must be between 1 and 65535)", c.AgentIngestPort)
}
if c.AgentIngestPort == c.FrontendPort {
return fmt.Errorf("agent ingest port (%d) must differ from frontend port", c.AgentIngestPort)
}
if c.HTTPRedirectPort != 0 && c.AgentIngestPort == c.HTTPRedirectPort {
return fmt.Errorf("agent ingest port (%d) must differ from HTTP redirect port", c.AgentIngestPort)
}
}
// Validate monitoring settings
if c.PVEPollingInterval < 10*time.Second {
return fmt.Errorf("PVE polling interval must be at least 10 seconds")
}
if c.PVEPollingInterval > time.Hour {
return fmt.Errorf("PVE polling interval cannot exceed 1 hour")
}
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)
}
for _, identity := range pve.ClusterNodeIdentities {
if _, err := NormalizePVEClusterNodeDisplayName(identity.DisplayName); err != nil {
return fmt.Errorf("PVE instance %d cluster node %q display name %s", i+1, identity.ID, err)
}
}
}
// 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
}
// 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 := osStat("/.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 := osStat("/etc/pve"); err == nil {
// We're likely in a ProxmoxVE container
// Try to get the container's IP from hostname -I
if output, err := execCommand("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 := netInterfaceAddrs(); 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 := netDial("udp", "8.8.8.8:80")
if err != nil {
// Try Cloudflare DNS as fallback
conn, err = netDial("udp", "1.1.1.1:80")
if err != nil {
return ""
}
}
defer conn.Close()
localAddr, ok := conn.LocalAddr().(*net.UDPAddr)
if !ok || localAddr == nil || localAddr.IP == nil {
return ""
}
return localAddr.IP.String()
}