package api import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/discovery" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/notifications" "github.com/rcourtman/pulse-go-rewrite/internal/telemetry" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" "github.com/rs/zerolog/log" ) // SystemSettingsMonitor defines the monitor interface needed by system settings type SystemSettingsMonitor interface { GetDiscoveryService() *discovery.Service StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string) StopDiscoveryService() EnableTemperatureMonitoring() DisableTemperatureMonitoring() GetNotificationManager() *notifications.NotificationManager } // SystemSettingsHandler handles system settings type SystemSettingsHandler struct { stateMu sync.RWMutex config *config.Config persistence *config.ConfigPersistence wsHub *websocket.Hub reloadSystemSettingsFunc func() // Function to reload cached system settings reloadMonitorFunc func() error telemetryToggleFunc func(enabled bool) // Called when telemetry is toggled at runtime telemetryPreviewFunc func() (telemetry.Ping, error) telemetryResetFunc func() (telemetry.Ping, error) mtMonitor interface { GetMonitor(string) (*monitoring.Monitor, error) } defaultMonitor SystemSettingsMonitor } type SystemSettingsResponse struct { config.SystemSettings EnvOverrides map[string]bool `json:"envOverrides"` } type TelemetryPreviewResponse struct { Enabled bool `json:"enabled"` Payload telemetry.Ping `json:"payload"` } func EmptySystemSettingsResponse() SystemSettingsResponse { return SystemSettingsResponse{}.NormalizeCollections() } func (r SystemSettingsResponse) NormalizeCollections() SystemSettingsResponse { if r.EnvOverrides == nil { r.EnvOverrides = map[string]bool{} } return r } // NewSystemSettingsHandler creates a new system settings handler func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPersistence, wsHub *websocket.Hub, mtm *monitoring.MultiTenantMonitor, monitor SystemSettingsMonitor, reloadSystemSettingsFunc func(), reloadMonitorFunc func() error) *SystemSettingsHandler { // If mtm is provided, try to populate defaultMonitor from "default" org if not provided. if monitor == nil && mtm != nil { if m, err := mtm.GetMonitor("default"); err == nil { monitor = m } } return &SystemSettingsHandler{ config: cfg, persistence: persistence, wsHub: wsHub, mtMonitor: mtm, defaultMonitor: monitor, reloadSystemSettingsFunc: reloadSystemSettingsFunc, reloadMonitorFunc: reloadMonitorFunc, } } // SetTelemetryToggleFunc sets the callback invoked when telemetry is toggled // at runtime (true = start, false = stop). func (h *SystemSettingsHandler) SetTelemetryToggleFunc(fn func(enabled bool)) { h.telemetryToggleFunc = fn } // SetTelemetryPreviewFunc sets the callback used to build the exact telemetry // payload preview exposed through the system settings API. func (h *SystemSettingsHandler) SetTelemetryPreviewFunc(fn func() (telemetry.Ping, error)) { h.telemetryPreviewFunc = fn } // SetTelemetryResetFunc sets the callback used to rotate the telemetry install // identifier and rebuild the current preview payload. func (h *SystemSettingsHandler) SetTelemetryResetFunc(fn func() (telemetry.Ping, error)) { h.telemetryResetFunc = fn } // SetMonitor updates the monitor reference used by the handler at runtime. func (h *SystemSettingsHandler) SetMonitor(m SystemSettingsMonitor) { h.stateMu.Lock() defer h.stateMu.Unlock() h.defaultMonitor = m } // SetMultiTenantMonitor updates the multi-tenant monitor reference func (h *SystemSettingsHandler) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) { var defaultMonitor SystemSettingsMonitor if mtm != nil { if m, err := mtm.GetMonitor("default"); err == nil { defaultMonitor = m } } h.stateMu.Lock() defer h.stateMu.Unlock() h.mtMonitor = mtm if defaultMonitor != nil { h.defaultMonitor = defaultMonitor } } // forEachNotificationManager applies fn to the notification manager of every // live tenant monitor when multi-tenant iteration is available, falling back // to the request's monitor otherwise. Instance-wide notification settings // (webhook security allowlist, public URL) must reach every org's manager. func (h *SystemSettingsHandler) forEachNotificationManager(r *http.Request, fn func(*notifications.NotificationManager)) { h.stateMu.RLock() mtMonitor := h.mtMonitor h.stateMu.RUnlock() type monitorRanger interface { ForEachMonitor(func(*monitoring.Monitor)) } if ranger, ok := mtMonitor.(monitorRanger); ok && ranger != nil { ranger.ForEachMonitor(func(m *monitoring.Monitor) { if nm := m.GetNotificationManager(); nm != nil { fn(nm) } }) return } if monitor := h.getMonitor(r.Context()); monitor != nil { if nm := monitor.GetNotificationManager(); nm != nil { fn(nm) } } } func (h *SystemSettingsHandler) getMonitor(ctx context.Context) SystemSettingsMonitor { h.stateMu.RLock() mtMonitor := h.mtMonitor defaultMonitor := h.defaultMonitor h.stateMu.RUnlock() if mtMonitor != nil { orgID := GetOrgID(ctx) if m, err := mtMonitor.GetMonitor(orgID); err == nil && m != nil { return m } } return defaultMonitor } // SetConfig updates the configuration reference used by the handler. func (h *SystemSettingsHandler) SetConfig(cfg *config.Config) { if cfg == nil { return } h.config = cfg } func (h *SystemSettingsHandler) ensureWriteAccess(w http.ResponseWriter, r *http.Request) bool { // Require authentication authWriter := &responseCapture{ResponseWriter: w} if !checkAuth(h.config, authWriter, r, false) { if !authWriter.wrote { writeErrorResponse(w, http.StatusUnauthorized, "unauthorized", "Unauthorized", nil) } return false } // Check if using proxy auth and if so, verify admin status if h.config.ProxyAuthSecret != "" { if valid, username, isAdmin := CheckProxyAuth(h.config, r); valid { if !isAdmin { log.Warn(). Str("ip", r.RemoteAddr). Str("path", r.URL.Path). Str("method", r.Method). Str("username", username). Msg("Non-admin user attempted to update system settings") writeErrorResponse(w, http.StatusForbidden, "forbidden", "Admin privileges required", nil) return false } } } // SECURITY: Session users must match configured admin identity for settings writes. if !ensureSettingsWriteScope(h.config, w, r) { return false } return true } func discoveryConfigMap(raw map[string]interface{}) (map[string]interface{}, bool) { if raw == nil { return nil, false } if val, ok := raw["discoveryConfig"]; ok { if cfgMap, ok := val.(map[string]interface{}); ok { return cfgMap, true } return nil, true } return nil, false } func parseDiscoveryStringArray(value interface{}, field string) ([]string, error) { items, ok := value.([]interface{}) if !ok { return nil, fmt.Errorf("%s must be an array of CIDR strings", field) } out := make([]string, 0, len(items)) for _, item := range items { entry, ok := item.(string) if !ok { return nil, fmt.Errorf("%s entries must be strings", field) } out = append(out, entry) } return out, nil } func parseDiscoveryWholeNumber(value interface{}, field string) (int, error) { raw, ok := value.(float64) if !ok { return 0, fmt.Errorf("%s must be a number", field) } if raw != float64(int(raw)) { return 0, fmt.Errorf("%s must be a whole number", field) } return int(raw), nil } func applyDiscoveryConfigOverrides(current config.DiscoveryConfig, cfgMap map[string]interface{}) (config.DiscoveryConfig, error) { if envVal, ok := cfgMap["environmentOverride"]; ok { envStr, ok := envVal.(string) if !ok { return current, fmt.Errorf("discoveryConfig.environmentOverride must be a string") } canonicalEnv, valid := config.CanonicalDiscoveryEnvironment(envStr) if !valid { return current, fmt.Errorf("invalid discovery environment override: %s", envStr) } current.EnvironmentOverride = canonicalEnv } if val, ok := cfgMap["subnetAllowlist"]; ok { allowlist, err := parseDiscoveryStringArray(val, "discoveryConfig.subnetAllowlist") if err != nil { return current, err } current.SubnetAllowlist = allowlist } if val, ok := cfgMap["subnetBlocklist"]; ok { blocklist, err := parseDiscoveryStringArray(val, "discoveryConfig.subnetBlocklist") if err != nil { return current, err } current.SubnetBlocklist = blocklist } if val, ok := cfgMap["maxHostsPerScan"]; ok { maxHosts, err := parseDiscoveryWholeNumber(val, "discoveryConfig.maxHostsPerScan") if err != nil { return current, err } current.MaxHostsPerScan = maxHosts } if val, ok := cfgMap["maxConcurrent"]; ok { maxConcurrent, err := parseDiscoveryWholeNumber(val, "discoveryConfig.maxConcurrent") if err != nil { return current, err } current.MaxConcurrent = maxConcurrent } if val, ok := cfgMap["enableReverseDns"]; ok { enabled, ok := val.(bool) if !ok { return current, fmt.Errorf("discoveryConfig.enableReverseDns must be a boolean") } current.EnableReverseDNS = enabled } if val, ok := cfgMap["scanGateways"]; ok { enabled, ok := val.(bool) if !ok { return current, fmt.Errorf("discoveryConfig.scanGateways must be a boolean") } current.ScanGateways = enabled } if val, ok := cfgMap["dialTimeoutMs"]; ok { dialTimeout, err := parseDiscoveryWholeNumber(val, "discoveryConfig.dialTimeoutMs") if err != nil { return current, err } current.DialTimeout = dialTimeout } if val, ok := cfgMap["httpTimeoutMs"]; ok { httpTimeout, err := parseDiscoveryWholeNumber(val, "discoveryConfig.httpTimeoutMs") if err != nil { return current, err } current.HTTPTimeout = httpTimeout } return config.NormalizeDiscoveryConfig(current), nil } // validateSystemSettings validates settings before applying them func validateSystemSettings(_ *config.SystemSettings, rawRequest map[string]interface{}) error { if val, ok := rawRequest["reportBranding"]; ok { settings, ok := val.(map[string]interface{}) if !ok { return fmt.Errorf("reportBranding must be an object") } if err := validateReportBrandingSettings(settings); err != nil { return err } } if val, ok := rawRequest["pvePollingInterval"]; ok { if interval, ok := val.(float64); ok { if interval <= 0 { return fmt.Errorf("PVE polling interval must be positive (minimum 10 seconds)") } if interval < 10 { return fmt.Errorf("PVE polling interval must be at least 10 seconds") } if interval > 3600 { return fmt.Errorf("PVE polling interval cannot exceed 3600 seconds (1 hour)") } } else { return fmt.Errorf("PVE polling interval must be a number") } } if val, ok := rawRequest["pbsPollingInterval"]; ok { if interval, ok := val.(float64); ok { if interval <= 0 { return fmt.Errorf("PBS polling interval must be positive (minimum 10 seconds)") } if interval < 10 { return fmt.Errorf("PBS polling interval must be at least 10 seconds") } if interval > 3600 { return fmt.Errorf("PBS polling interval cannot exceed 3600 seconds (1 hour)") } } else { return fmt.Errorf("PBS polling interval must be a number") } } if val, ok := rawRequest["pmgPollingInterval"]; ok { if interval, ok := val.(float64); ok { if interval <= 0 { return fmt.Errorf("PMG polling interval must be positive (minimum 10 seconds)") } if interval < 10 { return fmt.Errorf("PMG polling interval must be at least 10 seconds") } if interval > 3600 { return fmt.Errorf("PMG polling interval cannot exceed 3600 seconds (1 hour)") } } else { return fmt.Errorf("PMG polling interval must be a number") } } if val, ok := rawRequest["backupPollingInterval"]; ok { if interval, ok := val.(float64); ok { if interval < 0 { return fmt.Errorf("backup polling interval cannot be negative") } if interval > 0 && interval < 10 { return fmt.Errorf("backup polling interval must be at least 10 seconds") } if interval > 604800 { return fmt.Errorf("backup polling interval cannot exceed 604800 seconds (7 days)") } } else { return fmt.Errorf("backup polling interval must be a number") } } // Validate boolean fields have correct type if val, ok := rawRequest["autoUpdateEnabled"]; ok { if _, ok := val.(bool); !ok { return fmt.Errorf("autoUpdateEnabled must be a boolean") } } if val, ok := rawRequest["discoveryEnabled"]; ok { if _, ok := val.(bool); !ok { return fmt.Errorf("discoveryEnabled must be a boolean") } } if val, ok := rawRequest["allowEmbedding"]; ok { if _, ok := val.(bool); !ok { return fmt.Errorf("allowEmbedding must be a boolean") } } if val, ok := rawRequest["backupPollingEnabled"]; ok { if _, ok := val.(bool); !ok { return fmt.Errorf("backupPollingEnabled must be a boolean") } } if val, ok := rawRequest["temperatureMonitoringEnabled"]; ok { if _, ok := val.(bool); !ok { return fmt.Errorf("temperatureMonitoringEnabled must be a boolean") } } if val, ok := rawRequest["reduceProUpsellNoise"]; ok { if _, ok := val.(bool); !ok { return fmt.Errorf("reduceProUpsellNoise must be a boolean") } } // Validate auto-update check interval (min 1 hour, max 7 days) if val, ok := rawRequest["autoUpdateCheckInterval"]; ok { if interval, ok := val.(float64); ok { if interval < 0 { return fmt.Errorf("auto-update check interval cannot be negative") } if interval > 0 && interval < 1 { return fmt.Errorf("auto-update check interval must be at least 1 hour") } if interval > 168 { return fmt.Errorf("auto-update check interval cannot exceed 168 hours (7 days)") } } else { return fmt.Errorf("auto-update check interval must be a number") } } if cfgMap, cfgProvided := discoveryConfigMap(rawRequest); cfgProvided { if cfgMap == nil { return fmt.Errorf("discoveryConfig must be an object") } allowedDiscoveryConfigFields := map[string]struct{}{ "environmentOverride": {}, "subnetAllowlist": {}, "subnetBlocklist": {}, "maxHostsPerScan": {}, "maxConcurrent": {}, "enableReverseDns": {}, "scanGateways": {}, "dialTimeoutMs": {}, "httpTimeoutMs": {}, } for key := range cfgMap { if _, ok := allowedDiscoveryConfigFields[key]; !ok { return fmt.Errorf("discoveryConfig.%s is not supported", key) } } if envVal, exists := cfgMap["environmentOverride"]; exists { envStr, ok := envVal.(string) if !ok { return fmt.Errorf("discoveryConfig.environmentOverride must be a string") } if !config.IsValidDiscoveryEnvironment(envStr) { return fmt.Errorf("invalid discovery environment override: %s", envStr) } } if allowVal, exists := cfgMap["subnetAllowlist"]; exists { items, ok := allowVal.([]interface{}) if !ok { return fmt.Errorf("discoveryConfig.subnetAllowlist must be an array of CIDR strings") } for _, item := range items { cidr, ok := item.(string) if !ok { return fmt.Errorf("discoveryConfig.subnetAllowlist entries must be strings") } if _, _, err := net.ParseCIDR(cidr); err != nil { return fmt.Errorf("invalid CIDR in discoveryConfig.subnetAllowlist: %s", cidr) } } } if blockVal, exists := cfgMap["subnetBlocklist"]; exists { items, ok := blockVal.([]interface{}) if !ok { return fmt.Errorf("discoveryConfig.subnetBlocklist must be an array of CIDR strings") } for _, item := range items { cidr, ok := item.(string) if !ok { return fmt.Errorf("discoveryConfig.subnetBlocklist entries must be strings") } if _, _, err := net.ParseCIDR(cidr); err != nil { return fmt.Errorf("invalid CIDR in discoveryConfig.subnetBlocklist: %s", cidr) } } } if hostsVal, exists := cfgMap["maxHostsPerScan"]; exists { value, ok := hostsVal.(float64) if !ok { return fmt.Errorf("discoveryConfig.maxHostsPerScan must be a number") } if value <= 0 || value != float64(int(value)) { return fmt.Errorf("discoveryConfig.maxHostsPerScan must be a whole number greater than zero") } } if concurrentVal, exists := cfgMap["maxConcurrent"]; exists { value, ok := concurrentVal.(float64) if !ok { return fmt.Errorf("discoveryConfig.maxConcurrent must be a number") } if value <= 0 || value > 1000 || value != float64(int(value)) { return fmt.Errorf("discoveryConfig.maxConcurrent must be a whole number between 1 and 1000") } } if val, exists := cfgMap["enableReverseDns"]; exists { if _, ok := val.(bool); !ok { return fmt.Errorf("discoveryConfig.enableReverseDns must be a boolean") } } if val, exists := cfgMap["scanGateways"]; exists { if _, ok := val.(bool); !ok { return fmt.Errorf("discoveryConfig.scanGateways must be a boolean") } } if val, exists := cfgMap["dialTimeoutMs"]; exists { timeout, ok := val.(float64) if !ok { return fmt.Errorf("discoveryConfig.dialTimeoutMs must be a number") } if timeout <= 0 || timeout != float64(int(timeout)) { return fmt.Errorf("discoveryConfig.dialTimeoutMs must be a whole number greater than zero") } } if val, exists := cfgMap["httpTimeoutMs"]; exists { timeout, ok := val.(float64) if !ok { return fmt.Errorf("discoveryConfig.httpTimeoutMs must be a number") } if timeout <= 0 || timeout != float64(int(timeout)) { return fmt.Errorf("discoveryConfig.httpTimeoutMs must be a whole number greater than zero") } } } // Validate connection timeout (min 1 second, max 5 minutes) if val, ok := rawRequest["connectionTimeout"]; ok { if timeout, ok := val.(float64); ok { if timeout < 0 { return fmt.Errorf("connection timeout cannot be negative") } if timeout > 0 && timeout < 1 { return fmt.Errorf("connection timeout must be at least 1 second") } if timeout > 300 { return fmt.Errorf("connection timeout cannot exceed 300 seconds (5 minutes)") } } else { return fmt.Errorf("connection timeout must be a number") } } // Validate theme if val, ok := rawRequest["theme"]; ok { if theme, ok := val.(string); ok { if theme != "" && theme != "light" && theme != "dark" { return fmt.Errorf("theme must be 'light', 'dark', or empty") } } else { return fmt.Errorf("theme must be a string") } } // Validate update channel if val, ok := rawRequest["updateChannel"]; ok { if channel, ok := val.(string); ok { if channel != "" && channel != "stable" && channel != "rc" { return fmt.Errorf("update channel must be 'stable' or 'rc'") } } else { return fmt.Errorf("update channel must be a string") } } return nil } func validateReportBrandingSettings(settings map[string]interface{}) error { allowed := map[string]struct{}{ "displayName": {}, "logoBase64": {}, "logoFormat": {}, } for key, val := range settings { if _, ok := allowed[key]; !ok { return fmt.Errorf("reportBranding.%s is not supported", key) } str, ok := val.(string) if !ok { return fmt.Errorf("reportBranding.%s must be a string", key) } if strings.ContainsAny(str, "\r\n") { return fmt.Errorf("reportBranding.%s must not contain newlines", key) } switch key { case "displayName": if len(strings.TrimSpace(str)) > config.ReportBrandDisplayNameMaxLength { return fmt.Errorf("reportBranding.displayName must be <= %d characters", config.ReportBrandDisplayNameMaxLength) } case "logoBase64": if _, err := config.DecodeReportBrandLogoBase64(str); err != nil { return fmt.Errorf("reportBranding.%s", err.Error()) } case "logoFormat": if _, ok := config.CanonicalReportBrandLogoFormat(str); !ok { return fmt.Errorf("reportBranding.logoFormat must be png, jpg, jpeg, gif, or empty") } } } return nil } // HandleGetSystemSettings returns the current system settings func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) return } // SECURITY: Session users must match configured admin identity for settings reads. if !ensureSettingsReadScope(h.config, w, r) { return } settings, err := h.persistence.LoadSystemSettings() if err != nil { log.Error().Err(err).Msg("Failed to load system settings") settings = config.DefaultSystemSettings() } if settings == nil { settings = config.DefaultSystemSettings() } // Log loaded settings for debugging if settings != nil { log.Debug(). Str("theme", settings.Theme). Msg("Loaded system settings for API response") settings.UpdateChannel = config.EffectiveUpdateChannel(settings.UpdateChannel, h.config.UpdateChannel) // Always expose effective backup polling configuration settings.PVEPollingInterval = int(h.config.PVEPollingInterval.Seconds()) settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds()) enabled := h.config.EnableBackupPolling settings.BackupPollingEnabled = &enabled settings.DiscoveryConfig = config.CloneDiscoveryConfig(h.config.Discovery) settings.TemperatureMonitoringEnabled = h.config.TemperatureMonitoringEnabled // Expose Docker update actions setting (respects env override) settings.DisableDockerUpdateActions = h.config.DisableDockerUpdateActions // Expose effective telemetry value (respects env override) effectiveTelemetry := h.config.TelemetryEnabled settings.TelemetryEnabled = &effectiveTelemetry settings.AutoUpdateEnabled = config.EffectiveAutoUpdateEnabled(settings.UpdateChannel, settings.AutoUpdateEnabled) } // Include env override information response := EmptySystemSettingsResponse() if settings != nil { response.SystemSettings = *settings } response.EnvOverrides = h.config.EnvOverrides response = response.NormalizeCollections() if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write system settings response") } } // HandleGetTelemetryPreview returns the exact heartbeat payload Pulse would send // using the current runtime telemetry configuration. func (h *SystemSettingsHandler) HandleGetTelemetryPreview(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) return } if !ensureSettingsReadScope(h.config, w, r) { return } if h.telemetryPreviewFunc == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "telemetry_unavailable", "Telemetry preview is unavailable", nil) return } payload, err := h.telemetryPreviewFunc() if err != nil { log.Error().Err(err).Msg("Failed to build telemetry preview") writeErrorResponse(w, http.StatusInternalServerError, "telemetry_preview_failed", "Failed to build telemetry preview", nil) return } if err := utils.WriteJSONResponse(w, TelemetryPreviewResponse{ Enabled: h.config.TelemetryEnabled, Payload: payload, }); err != nil { log.Error().Err(err).Msg("Failed to write telemetry preview response") } } // HandleResetTelemetryID rotates the local telemetry install ID immediately and // returns the refreshed preview payload. func (h *SystemSettingsHandler) HandleResetTelemetryID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) return } if !h.ensureWriteAccess(w, r) { return } if h.telemetryResetFunc == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "telemetry_unavailable", "Telemetry reset is unavailable", nil) return } payload, err := h.telemetryResetFunc() if err != nil { log.Error().Err(err).Msg("Failed to reset telemetry install ID") writeErrorResponse(w, http.StatusInternalServerError, "telemetry_reset_failed", "Failed to reset telemetry install ID", nil) return } log.Info().Msg("Telemetry install ID reset") if err := utils.WriteJSONResponse(w, TelemetryPreviewResponse{ Enabled: h.config.TelemetryEnabled, Payload: payload, }); err != nil { log.Error().Err(err).Msg("Failed to write telemetry reset response") } } // HandleUpdateSystemSettings updates the system settings func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) return } if !h.ensureWriteAccess(w, r) { return } // Load existing settings first to preserve fields not in the request existingSettings, err := h.persistence.LoadSystemSettings() if err != nil { log.Error().Err(err).Msg("Failed to load existing settings") existingSettings = config.DefaultSystemSettings() } if existingSettings == nil { existingSettings = config.DefaultSystemSettings() } // Limit request body to 64KB to prevent memory exhaustion r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // Read the request body into a map to check which fields were provided var rawRequest map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&rawRequest); err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil) return } // Convert the map back to JSON for decoding into struct jsonBytes, err := json.Marshal(rawRequest) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil) return } // Decode into updates struct var updates config.SystemSettings if err := json.Unmarshal(jsonBytes, &updates); err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil) return } // Validate the settings if err := validateSystemSettings(&updates, rawRequest); err != nil { writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) return } // Reject early if telemetryEnabled is locked by env var if _, ok := rawRequest["telemetryEnabled"]; ok && h.config.EnvOverrides["PULSE_TELEMETRY"] { writeErrorResponse(w, http.StatusConflict, "env_locked", "telemetryEnabled is locked by the PULSE_TELEMETRY environment variable", nil) return } // Start with existing settings settings := *existingSettings discoveryConfigUpdated := false prevTempEnabled := h.config.TemperatureMonitoringEnabled tempToggleRequested := false pveIntervalChanged := false // Only update fields that were provided in the request if _, ok := rawRequest["pvePollingInterval"]; ok { settings.PVEPollingInterval = updates.PVEPollingInterval } if _, ok := rawRequest["pbsPollingInterval"]; ok { settings.PBSPollingInterval = updates.PBSPollingInterval } if _, ok := rawRequest["pmgPollingInterval"]; ok { settings.PMGPollingInterval = updates.PMGPollingInterval } if _, ok := rawRequest["backupPollingInterval"]; ok { settings.BackupPollingInterval = updates.BackupPollingInterval } if updates.AllowedOrigins != "" { settings.AllowedOrigins = updates.AllowedOrigins } if _, ok := rawRequest["connectionTimeout"]; ok { settings.ConnectionTimeout = updates.ConnectionTimeout } if updates.UpdateChannel != "" { settings.UpdateChannel = updates.UpdateChannel } if _, ok := rawRequest["autoUpdateCheckInterval"]; ok { settings.AutoUpdateCheckInterval = updates.AutoUpdateCheckInterval } if updates.AutoUpdateTime != "" { settings.AutoUpdateTime = updates.AutoUpdateTime } if updates.Theme != "" { settings.Theme = updates.Theme } if updates.DiscoverySubnet != "" { settings.DiscoverySubnet = updates.DiscoverySubnet } if cfgMap, ok := discoveryConfigMap(rawRequest); ok && cfgMap != nil { current := config.CloneDiscoveryConfig(settings.DiscoveryConfig) normalizedDiscoveryConfig, err := applyDiscoveryConfigOverrides(current, cfgMap) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "validation_error", err.Error(), nil) return } settings.DiscoveryConfig = normalizedDiscoveryConfig discoveryConfigUpdated = true } // Allow clearing of AllowedEmbedOrigins by setting to empty string if _, ok := rawRequest["allowedEmbedOrigins"]; ok { settings.AllowedEmbedOrigins = updates.AllowedEmbedOrigins } // Allow configuring webhook private CIDR allowlist if _, ok := rawRequest["webhookAllowedPrivateCIDRs"]; ok { settings.WebhookAllowedPrivateCIDRs = updates.WebhookAllowedPrivateCIDRs } // Allow configuring public URL for notifications (used in email alerts) if _, ok := rawRequest["publicURL"]; ok { settings.PublicURL = updates.PublicURL } // Allow per-runtime report branding overrides. The reporting layer still // enforces the white_label entitlement before rendering these values. if _, ok := rawRequest["reportBranding"]; ok { settings.ReportBranding = updates.ReportBranding } // Boolean fields need special handling since false is a valid value if _, ok := rawRequest["autoUpdateEnabled"]; ok { settings.AutoUpdateEnabled = updates.AutoUpdateEnabled } if _, ok := rawRequest["discoveryEnabled"]; ok { settings.DiscoveryEnabled = updates.DiscoveryEnabled } if _, ok := rawRequest["allowEmbedding"]; ok { settings.AllowEmbedding = updates.AllowEmbedding } if _, ok := rawRequest["hideLocalLogin"]; ok { settings.HideLocalLogin = updates.HideLocalLogin } if _, ok := rawRequest["backupPollingEnabled"]; ok { settings.BackupPollingEnabled = updates.BackupPollingEnabled } if _, ok := rawRequest["temperatureMonitoringEnabled"]; ok { settings.TemperatureMonitoringEnabled = updates.TemperatureMonitoringEnabled tempToggleRequested = true } if _, ok := rawRequest["disableDockerUpdateActions"]; ok { settings.DisableDockerUpdateActions = updates.DisableDockerUpdateActions } if _, ok := rawRequest["reduceProUpsellNoise"]; ok { settings.ReduceProUpsellNoise = updates.ReduceProUpsellNoise } if _, ok := rawRequest["telemetryEnabled"]; ok && updates.TelemetryEnabled != nil { settings.TelemetryEnabled = updates.TelemetryEnabled // Note: h.config.TelemetryEnabled is updated after successful persistence below } if _, ok := rawRequest["fullWidthMode"]; ok { settings.FullWidthMode = updates.FullWidthMode } settings.UpdateChannel = config.EffectiveUpdateChannel(settings.UpdateChannel, h.config.UpdateChannel) settings.AutoUpdateEnabled = config.EffectiveAutoUpdateEnabled(settings.UpdateChannel, settings.AutoUpdateEnabled) // Pre-save validation (may return errors before anything is persisted) if settings.Theme != "" && settings.Theme != "light" && settings.Theme != "dark" { writeErrorResponse(w, http.StatusBadRequest, "validation_error", "Invalid theme value. Must be 'light', 'dark', or empty", nil) return } // Validate CIDRs before persistence (parse only — do NOT mutate runtime yet). var parsedCIDRNets []*net.IPNet cidrUpdateRequested := false if _, ok := rawRequest["webhookAllowedPrivateCIDRs"]; ok { cidrUpdateRequested = true var err error parsedCIDRNets, err = notifications.ParseAllowedPrivateCIDRs(settings.WebhookAllowedPrivateCIDRs) if err != nil { log.Error().Err(err).Msg("Failed to validate webhook allowed private CIDRs") writeErrorResponse(w, http.StatusBadRequest, "validation_error", fmt.Sprintf("Invalid webhook allowed private CIDRs: %v", err), nil) return } } // Detect PVE interval change (compare before mutation, apply after save) if _, ok := rawRequest["pvePollingInterval"]; ok && settings.PVEPollingInterval > 0 { newInterval := time.Duration(settings.PVEPollingInterval) * time.Second if newInterval < 10*time.Second { newInterval = 10 * time.Second } if h.config.PVEPollingInterval != newInterval { pveIntervalChanged = true } } prevDiscoveryEnabled := h.config.DiscoveryEnabled // ---- Persist FIRST, then apply to in-memory config ---- // This ensures runtime state never diverges from disk on save failure. if err := h.persistence.SaveSystemSettings(settings); err != nil { log.Error().Err(err).Msg("Failed to save system settings") writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save settings", nil) return } // ---- Apply all in-memory config mutations (safe: disk is committed) ---- if _, ok := rawRequest["pvePollingInterval"]; ok && settings.PVEPollingInterval > 0 { newInterval := time.Duration(settings.PVEPollingInterval) * time.Second if newInterval < 10*time.Second { newInterval = 10 * time.Second } h.config.PVEPollingInterval = newInterval } if settings.AllowedOrigins != "" { h.config.AllowedOrigins = settings.AllowedOrigins } if settings.ConnectionTimeout > 0 { h.config.ConnectionTimeout = time.Duration(settings.ConnectionTimeout) * time.Second } if settings.PMGPollingInterval > 0 { h.config.PMGPollingInterval = time.Duration(settings.PMGPollingInterval) * time.Second } if _, ok := rawRequest["backupPollingInterval"]; ok { if settings.BackupPollingInterval <= 0 { h.config.BackupPollingInterval = 0 } else { h.config.BackupPollingInterval = time.Duration(settings.BackupPollingInterval) * time.Second } } if settings.BackupPollingEnabled != nil { h.config.EnableBackupPolling = *settings.BackupPollingEnabled } h.config.UpdateChannel = settings.UpdateChannel h.config.AutoUpdateEnabled = config.EffectiveAutoUpdateEnabled(settings.UpdateChannel, settings.AutoUpdateEnabled) if settings.AutoUpdateCheckInterval > 0 { h.config.AutoUpdateCheckInterval = time.Duration(settings.AutoUpdateCheckInterval) * time.Hour } if settings.AutoUpdateTime != "" { h.config.AutoUpdateTime = settings.AutoUpdateTime } h.config.DiscoveryEnabled = settings.DiscoveryEnabled if settings.DiscoverySubnet != "" { h.config.DiscoverySubnet = settings.DiscoverySubnet } h.config.Discovery = config.CloneDiscoveryConfig(settings.DiscoveryConfig) if tempToggleRequested { h.config.TemperatureMonitoringEnabled = settings.TemperatureMonitoringEnabled } h.config.DisableDockerUpdateActions = settings.DisableDockerUpdateActions if _, ok := rawRequest["telemetryEnabled"]; ok && settings.TelemetryEnabled != nil { h.config.TelemetryEnabled = *settings.TelemetryEnabled if h.telemetryToggleFunc != nil { h.telemetryToggleFunc(*settings.TelemetryEnabled) } } if _, ok := rawRequest["publicURL"]; ok { h.config.PublicURL = settings.PublicURL } // ---- Side effects (discovery, temperature, notifications) ---- if h.getMonitor(r.Context()) != nil { if settings.DiscoveryEnabled && !prevDiscoveryEnabled { subnet := h.config.DiscoverySubnet if subnet == "" { subnet = "auto" } h.getMonitor(r.Context()).StartDiscoveryService(context.Background(), h.wsHub, subnet) log.Info().Msg("Discovery service started via settings update") } else if !settings.DiscoveryEnabled && prevDiscoveryEnabled { h.getMonitor(r.Context()).StopDiscoveryService() log.Info().Msg("Discovery service stopped via settings update") } else if settings.DiscoveryEnabled && settings.DiscoverySubnet != "" { if svc := h.getMonitor(r.Context()).GetDiscoveryService(); svc != nil { svc.SetSubnet(settings.DiscoverySubnet) } } if discoveryConfigUpdated && settings.DiscoveryEnabled { if svc := h.getMonitor(r.Context()).GetDiscoveryService(); svc != nil { log.Info().Msg("Discovery configuration changed; triggering refresh") svc.ForceRefresh() } } } if tempToggleRequested && h.getMonitor(r.Context()) != nil { if settings.TemperatureMonitoringEnabled && !prevTempEnabled { h.getMonitor(r.Context()).EnableTemperatureMonitoring() } else if !settings.TemperatureMonitoringEnabled && prevTempEnabled { h.getMonitor(r.Context()).DisableTemperatureMonitoring() } } // Webhook security allowlist and public URL are instance-wide settings: // apply them to every live tenant monitor's notification manager, not // just the org the admin happened to have selected. if cidrUpdateRequested { h.forEachNotificationManager(r, func(nm *notifications.NotificationManager) { nm.ApplyAllowedPrivateCIDRs(settings.WebhookAllowedPrivateCIDRs, parsedCIDRNets) }) } if _, ok := rawRequest["publicURL"]; ok { h.forEachNotificationManager(r, func(nm *notifications.NotificationManager) { nm.SetPublicURL(settings.PublicURL) }) log.Info().Str("publicURL", settings.PublicURL).Msg("Updated notification public URL from settings") } // Reload cached system settings after successful save if h.reloadSystemSettingsFunc != nil { h.reloadSystemSettingsFunc() } if pveIntervalChanged && h.reloadMonitorFunc != nil { if err := h.reloadMonitorFunc(); err != nil { log.Error().Err(err).Msg("Failed to reload monitor after PVE polling interval change") writeErrorResponse(w, http.StatusInternalServerError, "reload_failed", "Configuration saved but failed to reload monitor", nil) return } } log.Info().Msg("System settings updated") // Broadcast theme change to all connected clients if theme was updated if settings.Theme != "" && h.wsHub != nil { h.wsHub.BroadcastMessage(websocket.Message{ Type: "settingsUpdate", Data: map[string]interface{}{ "theme": settings.Theme, }, }) log.Debug().Str("theme", settings.Theme).Msg("Broadcasting theme change to WebSocket clients") } if err := utils.WriteJSONResponse(w, map[string]bool{"success": true}); err != nil { log.Error().Err(err).Msg("Failed to write system settings update response") } } // HandleSSHConfig writes SSH configuration for Pulse user func (h *SystemSettingsHandler) HandleSSHConfig(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil) return } // Limit request body to 32KB to prevent memory exhaustion r.Body = http.MaxBytesReader(w, r.Body, 32*1024) defer r.Body.Close() // Read SSH config content from request body sshConfig, err := io.ReadAll(r.Body) if err != nil { // Check if body was too large var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { log.Warn().Msg("SSH config request body too large") writeErrorResponse(w, http.StatusRequestEntityTooLarge, "request_too_large", "Request body too large", nil) return } log.Error().Err(err).Msg("Failed to read SSH config from request") writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Failed to read request body", nil) return } // Basic validation: ensure it looks like SSH config configStr := string(sshConfig) if len(configStr) == 0 { log.Error().Msg("Empty SSH config received") writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Empty SSH config", nil) return } // Security: Use allowlist-based validation (safer than blocklist) // Only permit the specific directives Pulse needs for ProxyJump allowedDirectives := map[string]bool{ "host": true, "hostname": true, "proxyjump": true, "user": true, "identityfile": true, "stricthostkeychecking": true, } // Parse and validate each line scanner := bufio.NewScanner(strings.NewReader(configStr)) lineNum := 0 for scanner.Scan() { lineNum++ line := scanner.Text() // Strip comments if idx := strings.Index(line, "#"); idx >= 0 { line = line[:idx] } // Skip empty lines and whitespace line = strings.TrimSpace(line) if line == "" { continue } // Extract directive (first word) fields := strings.Fields(line) if len(fields) == 0 { continue } directive := strings.ToLower(fields[0]) if !allowedDirectives[directive] { log.Warn(). Str("directive", fields[0]). Int("line", lineNum). Msg("Rejected SSH config with forbidden directive") writeErrorResponse(w, http.StatusBadRequest, "validation_error", fmt.Sprintf("SSH config contains forbidden directive: %s", fields[0]), nil) return } } if err := scanner.Err(); err != nil { log.Error().Err(err).Msg("Failed to parse SSH config") writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid SSH config format", nil) return } // Get the Pulse user's home directory homeDir := os.Getenv("HOME") if homeDir == "" { homeDir = "/home/pulse" // fallback } // Create .ssh directory if it doesn't exist sshDir := filepath.Join(homeDir, ".ssh") if err := os.MkdirAll(sshDir, 0700); err != nil { log.Error().Err(err).Str("dir", sshDir).Msg("Failed to create .ssh directory") writeErrorResponse(w, http.StatusInternalServerError, "internal_error", "Failed to create SSH directory", nil) return } // Harden permissions even when the directory already existed. if err := os.Chmod(sshDir, 0700); err != nil { log.Error().Err(err).Str("dir", sshDir).Msg("Failed to set .ssh directory permissions") writeErrorResponse(w, http.StatusInternalServerError, "internal_error", "Failed to secure SSH directory", nil) return } // Write SSH config file configPath := filepath.Join(sshDir, "config") if err := os.WriteFile(configPath, sshConfig, 0600); err != nil { log.Error().Err(err).Str("path", configPath).Msg("Failed to write SSH config") writeErrorResponse(w, http.StatusInternalServerError, "internal_error", "Failed to write SSH config", nil) return } // os.WriteFile does not change mode on existing files; enforce least-privilege. if err := os.Chmod(configPath, 0600); err != nil { log.Error().Err(err).Str("path", configPath).Msg("Failed to set SSH config file permissions") writeErrorResponse(w, http.StatusInternalServerError, "internal_error", "Failed to secure SSH config", nil) return } log.Info().Str("path", configPath).Int("size", len(sshConfig)).Msg("SSH config written successfully") w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil { log.Error().Err(err).Msg("Failed to encode success response") } }