mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
Remove local upgrade-metrics API registration, settings payload wiring, startup store migration, and backend conversion recorder hooks from the normal product runtime. Delete the retired conversion/funnel and metering packages from compiled licensing code, and extend diagnostics boundary audits and governance contracts so maintainer commercial analytics cannot return through Settings or diagnostics.
1169 lines
39 KiB
Go
1169 lines
39 KiB
Go
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
|
|
}
|
|
}
|
|
|
|
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["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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
if cidrUpdateRequested && h.getMonitor(r.Context()) != nil {
|
|
if nm := h.getMonitor(r.Context()).GetNotificationManager(); nm != nil {
|
|
nm.ApplyAllowedPrivateCIDRs(settings.WebhookAllowedPrivateCIDRs, parsedCIDRNets)
|
|
}
|
|
}
|
|
if _, ok := rawRequest["publicURL"]; ok {
|
|
if h.getMonitor(r.Context()) != nil {
|
|
if nm := h.getMonitor(r.Context()).GetNotificationManager(); nm != nil {
|
|
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")
|
|
}
|
|
}
|