diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go index f922137f6..c4817cde0 100644 --- a/internal/api/diagnostics.go +++ b/internal/api/diagnostics.go @@ -8,13 +8,11 @@ import ( "net/http" "os" "os/user" - "path/filepath" "runtime" "sort" "strconv" "strings" "sync" - "syscall" "time" "github.com/prometheus/client_golang/prometheus" @@ -22,7 +20,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" - "github.com/rcourtman/pulse-go-rewrite/internal/tempproxy" "github.com/rcourtman/pulse-go-rewrite/internal/updates" "github.com/rcourtman/pulse-go-rewrite/pkg/pbs" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" @@ -32,19 +29,18 @@ import ( // DiagnosticsInfo contains comprehensive diagnostic information type DiagnosticsInfo struct { - Version string `json:"version"` - Runtime string `json:"runtime"` - Uptime float64 `json:"uptime"` - Nodes []NodeDiagnostic `json:"nodes"` - PBS []PBSDiagnostic `json:"pbs"` - System SystemDiagnostic `json:"system"` - Discovery *DiscoveryDiagnostic `json:"discovery,omitempty"` - TemperatureProxy *TemperatureProxyDiagnostic `json:"temperatureProxy,omitempty"` - APITokens *APITokenDiagnostic `json:"apiTokens,omitempty"` - DockerAgents *DockerAgentDiagnostic `json:"dockerAgents,omitempty"` - Alerts *AlertsDiagnostic `json:"alerts,omitempty"` - AIChat *AIChatDiagnostic `json:"aiChat,omitempty"` - Errors []string `json:"errors"` + Version string `json:"version"` + Runtime string `json:"runtime"` + Uptime float64 `json:"uptime"` + Nodes []NodeDiagnostic `json:"nodes"` + PBS []PBSDiagnostic `json:"pbs"` + System SystemDiagnostic `json:"system"` + Discovery *DiscoveryDiagnostic `json:"discovery,omitempty"` + APITokens *APITokenDiagnostic `json:"apiTokens,omitempty"` + DockerAgents *DockerAgentDiagnostic `json:"dockerAgents,omitempty"` + Alerts *AlertsDiagnostic `json:"alerts,omitempty"` + AIChat *AIChatDiagnostic `json:"aiChat,omitempty"` + Errors []string `json:"errors"` // NodeSnapshots captures the raw memory payload and derived usage Pulse last observed per node. NodeSnapshots []monitoring.NodeMemorySnapshot `json:"nodeSnapshots,omitempty"` // GuestSnapshots captures recent per-guest memory breakdowns (VM/LXC) with the raw Proxmox fields. @@ -233,59 +229,6 @@ type SystemDiagnostic struct { MemoryMB uint64 `json:"memoryMB"` } -// TemperatureProxyDiagnostic summarizes proxy detection state -type TemperatureProxyDiagnostic struct { - SocketFound bool `json:"socketFound"` - SocketPath string `json:"socketPath,omitempty"` - SocketPermissions string `json:"socketPermissions,omitempty"` - SocketOwner string `json:"socketOwner,omitempty"` - SocketGroup string `json:"socketGroup,omitempty"` - ProxyReachable bool `json:"proxyReachable"` - ProxyVersion string `json:"proxyVersion,omitempty"` - ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"` - ProxySSHDirectory string `json:"proxySshDirectory,omitempty"` - LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"` - ProxyCapabilities []string `json:"proxyCapabilities,omitempty"` - Notes []string `json:"notes,omitempty"` - HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"` - ControlPlaneEnabled bool `json:"controlPlaneEnabled"` - ControlPlaneStates []TemperatureProxyControlPlaneState `json:"controlPlaneStates,omitempty"` - SocketHostCooldowns []TemperatureProxySocketHost `json:"socketHostCooldowns,omitempty"` - HostProxySummary *HostProxySummary `json:"hostProxySummary,omitempty"` -} - -type TemperatureProxyControlPlaneState struct { - Instance string `json:"instance"` - LastSync string `json:"lastSync,omitempty"` - RefreshIntervalSeconds int `json:"refreshIntervalSeconds,omitempty"` - SecondsBehind int `json:"secondsBehind,omitempty"` - Status string `json:"status,omitempty"` -} - -type TemperatureProxyHTTPStatus struct { - Node string `json:"node"` - URL string `json:"url"` - Reachable bool `json:"reachable"` - Error string `json:"error,omitempty"` -} - -type TemperatureProxySocketHost struct { - Node string `json:"node,omitempty"` - Host string `json:"host,omitempty"` - CooldownUntil string `json:"cooldownUntil,omitempty"` - SecondsRemaining int `json:"secondsRemaining,omitempty"` - LastError string `json:"lastError,omitempty"` -} - -type HostProxySummary struct { - Requested bool `json:"requested"` - Installed bool `json:"installed"` - HostSocketPresent bool `json:"hostSocketPresent"` - ContainerSocketPresent *bool `json:"containerSocketPresent,omitempty"` - LastUpdated string `json:"lastUpdated,omitempty"` - CTID string `json:"ctid,omitempty"` -} - // APITokenDiagnostic reports on the state of the multi-token authentication system. type APITokenDiagnostic struct { Enabled bool `json:"enabled"` @@ -446,22 +389,6 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo { MemoryMB: memStats.Alloc / 1024 / 1024, } - var ( - proxySync map[string]proxySyncState - socketHostState []monitoring.ProxyHostDiagnostics - ) - if r.temperatureProxyHandlers != nil { - proxySync = r.temperatureProxyHandlers.SnapshotSyncStatus() - } - if r.monitor != nil { - socketHostState = r.monitor.SocketProxyHostDiagnostics() - } - - if r.config != nil && !r.config.EnableSensorProxy { - diag.TemperatureProxy = nil - } else { - diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config, proxySync, socketHostState) - } diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor) // Test each configured node @@ -718,311 +645,13 @@ func buildDiscoveryDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) * return discovery } -func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]proxySyncState, hostStates []monitoring.ProxyHostDiagnostics) *TemperatureProxyDiagnostic { - diag := &TemperatureProxyDiagnostic{} - - appendNote := func(note string) { - if note == "" || contains(diag.Notes, note) { - return - } - diag.Notes = append(diag.Notes, note) - } - - socketPaths := []string{ - "/mnt/pulse-proxy/pulse-sensor-proxy.sock", - "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock", - } - - for _, path := range socketPaths { - info, err := os.Stat(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - continue - } - appendNote(fmt.Sprintf("Unable to inspect proxy socket at %s: %v", path, err)) - continue - } - - if info.Mode()&os.ModeSocket == 0 { - continue - } - - diag.SocketFound = true - diag.SocketPath = path - diag.SocketPermissions = fmt.Sprintf("%#o", info.Mode().Perm()) - - if stat, ok := info.Sys().(*syscall.Stat_t); ok { - diag.SocketOwner = resolveUserName(stat.Uid) - diag.SocketGroup = resolveGroupName(stat.Gid) - } - break - } - - if !diag.SocketFound { - appendNote("No proxy socket detected inside the container. Remove the affected node in Pulse, then re-add it using the installer script from Settings → Nodes to regenerate the mount (or rerun the host installer script if you prefer).") - if cfg != nil && cfg.TemperatureMonitoringEnabled { - appendNote("Global temperature monitoring is enabled but the host proxy socket is missing; reinstall the proxy or disable temperatures until it is restored.") - } - } else if diag.SocketPath == "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" { - // Only warn about /run mount in LXC containers where /mnt/pulse-proxy is preferred - // Docker deployments correctly use /run/pulse-sensor-proxy per docker-compose.yml - isDocker := os.Getenv("PULSE_DOCKER") == "true" - if !isDocker { - // In LXC, /run mount indicates legacy hand-crafted mount instead of managed mount - appendNote("Proxy socket is exposed via /run. Remove and re-add this node with the Settings → Nodes installer script so the managed /mnt/pulse-proxy mount is applied (advanced: rerun the host installer script).") - } - } - - client := tempproxy.NewClient() - if client != nil && client.IsAvailable() { - diag.ProxyReachable = true - if status, err := client.GetStatus(); err != nil { - appendNote(fmt.Sprintf("Failed to query pulse-sensor-proxy status: %v", err)) - } else { - if version, ok := status["version"].(string); ok { - diag.ProxyVersion = strings.TrimSpace(version) - } - if sshDir, ok := status["ssh_dir"].(string); ok { - diag.ProxySSHDirectory = sshDir - } - if pubKey, ok := status["public_key"].(string); ok { - if fingerprint, err := fingerprintPublicKey(pubKey); err == nil { - diag.ProxyPublicKeySHA256 = fingerprint - } else { - appendNote(fmt.Sprintf("Unable to fingerprint proxy public key: %v", err)) - } - } - if rawCaps, ok := status["capabilities"]; ok { - if caps := interfaceToStringSlice(rawCaps); len(caps) > 0 { - diag.ProxyCapabilities = caps - if !containsFold(caps, "admin") { - appendNote("Proxy socket is running in read-only mode, so 'Check proxy nodes' must be run from the Proxmox host or via an HTTP-mode proxy.") - } - } - } - } - } else { - if diag.SocketFound { - appendNote("Proxy socket is present but the daemon did not respond. Verify pulse-sensor-proxy.service is running on the host.") - } else { - appendNote("pulse-sensor-proxy was not detected. Run the host installer script to harden temperature monitoring.") - } - } - - if cfg != nil { - dataDir := strings.TrimSpace(cfg.DataPath) - if dataDir == "" { - dataDir = "/etc/pulse" - } - if count, err := countLegacySSHKeys(filepath.Join(dataDir, ".ssh")); err != nil { - appendNote(fmt.Sprintf("Unable to inspect legacy SSH directory: %v", err)) - } else if count > 0 { - diag.LegacySSHKeyCount = count - appendNote(fmt.Sprintf("Found %d SSH key(s) inside the Pulse data directory. Remove them after migrating to the secure proxy.", count)) - } - - for _, inst := range cfg.PVEInstances { - url := strings.TrimSpace(inst.TemperatureProxyURL) - if url == "" { - continue - } - - status := TemperatureProxyHTTPStatus{ - Node: strings.TrimSpace(inst.Name), - URL: url, - } - - token := strings.TrimSpace(inst.TemperatureProxyToken) - if token == "" { - status.Error = "missing authentication token" - } else { - client := tempproxy.NewHTTPClient(url, token) - if err := client.HealthCheck(); err != nil { - status.Error = err.Error() - } else { - status.Reachable = true - } - } - diag.HTTPProxies = append(diag.HTTPProxies, status) - } - - controlStates := make([]TemperatureProxyControlPlaneState, 0) - now := time.Now() - - lookupState := func(name string) (proxySyncState, bool) { - if len(syncStates) == 0 { - return proxySyncState{}, false - } - key := strings.ToLower(strings.TrimSpace(name)) - if state, ok := syncStates[key]; ok { - return state, true - } - for _, state := range syncStates { - if strings.EqualFold(state.Instance, name) { - return state, true - } - } - return proxySyncState{}, false - } - - for _, inst := range cfg.PVEInstances { - if strings.TrimSpace(inst.TemperatureProxyControlToken) == "" { - continue - } - - state := TemperatureProxyControlPlaneState{ - Instance: strings.TrimSpace(inst.Name), - Status: "pending", - RefreshIntervalSeconds: defaultProxyAllowlistRefreshSeconds, - } - diag.ControlPlaneEnabled = true - - if syncState, ok := lookupState(inst.Name); ok { - if syncState.RefreshInterval > 0 { - state.RefreshIntervalSeconds = syncState.RefreshInterval - } - if !syncState.LastPull.IsZero() { - state.LastSync = syncState.LastPull.UTC().Format(time.RFC3339) - behind := int(now.Sub(syncState.LastPull).Seconds()) - if behind < 0 { - behind = 0 - } - state.SecondsBehind = behind - - switch { - case behind <= state.RefreshIntervalSeconds+15: - state.Status = "healthy" - case behind <= state.RefreshIntervalSeconds*4: - state.Status = "stale" - appendNote(fmt.Sprintf("Proxy '%s' has not refreshed its authorized nodes for %d seconds (target %d). Verify pulse-sensor-proxy is running.", state.Instance, behind, state.RefreshIntervalSeconds)) - default: - state.Status = "offline" - appendNote(fmt.Sprintf("Proxy '%s' missed the control plane for %d seconds. Check connectivity and restart pulse-sensor-proxy.", state.Instance, behind)) - } - } else { - appendNote(fmt.Sprintf("Proxy '%s' registered for control plane sync but has not completed its first pull yet.", state.Instance)) - } - } else { - appendNote(fmt.Sprintf("Proxy '%s' has control-plane sync enabled but has not contacted Pulse. Confirm the installer wrote the control token and the host has connectivity.", state.Instance)) - } - - controlStates = append(controlStates, state) - } - - if len(controlStates) > 0 { - diag.ControlPlaneStates = controlStates - } - } - - if len(hostStates) > 0 && cfg != nil { - now := time.Now() - cooldowns := make([]TemperatureProxySocketHost, 0, len(hostStates)) - for _, state := range hostStates { - if state.Host == "" || state.CooldownUntil.IsZero() { - continue - } - if now.After(state.CooldownUntil) { - continue - } - entry := TemperatureProxySocketHost{ - Host: state.Host, - CooldownUntil: state.CooldownUntil.UTC().Format(time.RFC3339), - SecondsRemaining: int(time.Until(state.CooldownUntil).Seconds()), - LastError: state.LastError, - } - if entry.SecondsRemaining < 0 { - entry.SecondsRemaining = 0 - } - if name := matchInstanceNameByHost(cfg, state.Host); name != "" { - entry.Node = name - } - cooldowns = append(cooldowns, entry) - } - if len(cooldowns) > 0 { - diag.SocketHostCooldowns = cooldowns - } - } - - if summary, err := loadHostProxySummary(); err == nil { - diag.HostProxySummary = summary - } - - return diag -} - -func loadHostProxySummary() (*HostProxySummary, error) { - const summaryPath = "/etc/pulse/install_summary.json" - data, err := os.ReadFile(summaryPath) - if err != nil { - return nil, err - } - var raw struct { - GeneratedAt string `json:"generatedAt"` - CTID string `json:"ctid"` - Proxy struct { - Requested bool `json:"requested"` - Installed bool `json:"installed"` - HostSocketPresent bool `json:"hostSocketPresent"` - ContainerSocketPresent *bool `json:"containerSocketPresent"` - } `json:"proxy"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return nil, err - } - summary := &HostProxySummary{ - Requested: raw.Proxy.Requested, - Installed: raw.Proxy.Installed, - HostSocketPresent: raw.Proxy.HostSocketPresent, - LastUpdated: strings.TrimSpace(raw.GeneratedAt), - CTID: strings.TrimSpace(raw.CTID), - } - if raw.Proxy.ContainerSocketPresent != nil { - value := *raw.Proxy.ContainerSocketPresent - summary.ContainerSocketPresent = &value - } - return summary, nil -} - -func matchInstanceNameByHost(cfg *config.Config, host string) string { - if cfg == nil { - return "" - } - needle := normalizeHostForComparison(host) - if needle == "" { - return "" - } - for _, inst := range cfg.PVEInstances { - candidate := normalizeHostForComparison(inst.Host) - if candidate != "" && strings.EqualFold(candidate, needle) { - return strings.TrimSpace(inst.Name) - } - } - return "" -} - -func normalizeHostForComparison(raw string) string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "" - } - trimmed = strings.TrimPrefix(trimmed, "https://") - trimmed = strings.TrimPrefix(trimmed, "http://") - if idx := strings.IndexByte(trimmed, '/'); idx != -1 { - trimmed = trimmed[:idx] - } - if idx := strings.IndexByte(trimmed, ':'); idx != -1 { - trimmed = trimmed[:idx] - } - return strings.ToLower(strings.TrimSpace(trimmed)) -} - func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *APITokenDiagnostic { if cfg == nil { return nil } diag := &APITokenDiagnostic{ - Enabled: cfg.APITokenEnabled, + Enabled: len(cfg.APITokens) > 0, TokenCount: len(cfg.APITokens), } @@ -1053,9 +682,7 @@ func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *A diag.RecommendTokenSetup = len(cfg.APITokens) == 0 diag.RecommendTokenRotation = envTokens || legacyToken - if !cfg.APITokenEnabled && len(cfg.APITokens) > 0 { - appendNote("API token authentication is currently disabled. Enable it under Settings → Security so agents can use dedicated tokens.") - } else if diag.RecommendTokenSetup { + if diag.RecommendTokenSetup { appendNote("No API tokens are configured. Open Settings → Security to generate dedicated tokens for each automation or agent.") } diff --git a/internal/api/diagnostics_test.go b/internal/api/diagnostics_test.go index ea0d2732d..414e61411 100644 --- a/internal/api/diagnostics_test.go +++ b/internal/api/diagnostics_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" - "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" ) @@ -98,51 +97,6 @@ func TestCopyStringSlice(t *testing.T) { } } -func TestNormalizeHostForComparison(t *testing.T) { - tests := []struct { - input string - expected string - }{ - // Empty/whitespace - {"", ""}, - {" ", ""}, - - // Basic hostnames - {"example.com", "example.com"}, - {"Example.COM", "example.com"}, - {" example.com ", "example.com"}, - - // With protocol (lowercase only - uppercase protocols not stripped) - {"https://example.com", "example.com"}, - {"http://example.com", "example.com"}, - // Note: uppercase protocols are not stripped, result is lowercase of input - {"HTTPS://Example.COM", "https"}, - - // With port - {"example.com:8006", "example.com"}, - {"https://example.com:8006", "example.com"}, - {"192.168.1.1:8006", "192.168.1.1"}, - - // With path - {"example.com/api/v1", "example.com"}, - {"https://example.com/api/v1", "example.com"}, - {"https://example.com:8006/api/v1", "example.com"}, - - // IP addresses - {"192.168.1.1", "192.168.1.1"}, - {"https://192.168.1.1:8006", "192.168.1.1"}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := normalizeHostForComparison(tt.input) - if result != tt.expected { - t.Errorf("normalizeHostForComparison(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} - func TestNormalizeVersionLabel(t *testing.T) { tests := []struct { input string @@ -414,166 +368,6 @@ func TestFormatTimeMaybe(t *testing.T) { } } -func TestMatchInstanceNameByHost(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - cfg *config.Config - host string - expected string - }{ - { - name: "nil config", - cfg: nil, - host: "example.com", - expected: "", - }, - { - name: "empty host", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "pve1", Host: "pve1.local"}, - }, - }, - host: "", - expected: "", - }, - { - name: "whitespace-only host", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "pve1", Host: "pve1.local"}, - }, - }, - host: " ", - expected: "", - }, - { - name: "exact match", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "pve1", Host: "pve1.local"}, - {Name: "pve2", Host: "pve2.local"}, - }, - }, - host: "pve1.local", - expected: "pve1", - }, - { - name: "case insensitive match", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "Production PVE", Host: "PVE.EXAMPLE.COM"}, - }, - }, - host: "pve.example.com", - expected: "Production PVE", - }, - { - name: "match with port in config", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "pve1", Host: "pve1.local:8006"}, - }, - }, - host: "pve1.local", - expected: "pve1", - }, - { - name: "match with protocol in config", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "pve1", Host: "https://pve1.local:8006"}, - }, - }, - host: "pve1.local", - expected: "pve1", - }, - { - name: "match with protocol in search host", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "pve1", Host: "pve1.local"}, - }, - }, - host: "https://pve1.local:8006", - expected: "pve1", - }, - { - name: "no match", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "pve1", Host: "pve1.local"}, - {Name: "pve2", Host: "pve2.local"}, - }, - }, - host: "pve3.local", - expected: "", - }, - { - name: "empty instances", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{}, - }, - host: "pve1.local", - expected: "", - }, - { - name: "instance with empty host skipped", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "empty", Host: ""}, - {Name: "pve1", Host: "pve1.local"}, - }, - }, - host: "pve1.local", - expected: "pve1", - }, - { - name: "IP address match", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "pve1", Host: "192.168.1.100"}, - }, - }, - host: "192.168.1.100", - expected: "pve1", - }, - { - name: "name has leading and trailing whitespace", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: " pve1 ", Host: "pve1.local"}, - }, - }, - host: "pve1.local", - expected: "pve1", - }, - { - name: "returns first match when duplicates exist", - cfg: &config.Config{ - PVEInstances: []config.PVEInstance{ - {Name: "first", Host: "pve.local"}, - {Name: "second", Host: "pve.local"}, - }, - }, - host: "pve.local", - expected: "first", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - result := matchInstanceNameByHost(tt.cfg, tt.host) - if result != tt.expected { - t.Errorf("matchInstanceNameByHost() = %q, want %q", result, tt.expected) - } - }) - } -} - func TestHasLegacyThresholds(t *testing.T) { ptrFloat := func(v float64) *float64 { return &v } diff --git a/internal/api/router.go b/internal/api/router.go index 1710297d9..bce996612 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" - "errors" "fmt" "io" "net" @@ -36,7 +35,6 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/system" - "github.com/rcourtman/pulse-go-rewrite/internal/tempproxy" "github.com/rcourtman/pulse-go-rewrite/internal/updates" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" @@ -56,7 +54,6 @@ type Router struct { dockerAgentHandlers *DockerAgentHandlers kubernetesAgentHandlers *KubernetesAgentHandlers hostAgentHandlers *HostAgentHandlers - temperatureProxyHandlers *TemperatureProxyHandlers systemSettingsHandler *SystemSettingsHandler aiSettingsHandler *AISettingsHandler aiHandler *AIHandler // AI chat handler @@ -211,7 +208,6 @@ func (r *Router) setupRoutes() { r.dockerAgentHandlers = NewDockerAgentHandlers(r.monitor, r.wsHub, r.config) r.kubernetesAgentHandlers = NewKubernetesAgentHandlers(r.monitor, r.wsHub) r.hostAgentHandlers = NewHostAgentHandlers(r.monitor, r.wsHub) - r.temperatureProxyHandlers = NewTemperatureProxyHandlers(r.config, r.persistence, r.reloadFunc) r.resourceHandlers = NewResourceHandlers() r.configProfileHandler = NewConfigProfileHandler(r.persistence) r.licenseHandlers = NewLicenseHandlers(r.config.DataPath) @@ -253,11 +249,6 @@ func (r *Router) setupRoutes() { } http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) })) - r.mux.HandleFunc("/api/temperature-proxy/register", r.requireSensorProxyEnabled(r.temperatureProxyHandlers.HandleRegister)) - r.mux.HandleFunc("/api/temperature-proxy/authorized-nodes", r.requireSensorProxyEnabled(r.temperatureProxyHandlers.HandleAuthorizedNodes)) - r.mux.HandleFunc("/api/temperature-proxy/unregister", r.requireSensorProxyEnabled(RequireAdmin(r.config, r.temperatureProxyHandlers.HandleUnregister))) - r.mux.HandleFunc("/api/temperature-proxy/install-command", r.requireSensorProxyEnabled(RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleTemperatureProxyInstallCommand)))) - r.mux.HandleFunc("/api/temperature-proxy/host-status", r.requireSensorProxyEnabled(RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleHostProxyStatus)))) r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck))) r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions))) r.mux.HandleFunc("/api/agents/docker/containers/update", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleContainerUpdate))) @@ -269,12 +260,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/metrics-store/stats", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleMetricsStoreStats))) r.mux.HandleFunc("/api/metrics-store/history", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleMetricsHistory))) r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics)) - r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", r.requireSensorProxyEnabled(RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsRegisterProxyNodes)))) r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleDiagnosticsDockerPrepareToken))) - r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.requireSensorProxyEnabled(r.handleDownloadPulseSensorProxy)) - r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.requireSensorProxyEnabled(r.handleDownloadInstallerScript)) - r.mux.HandleFunc("/api/install/migrate-sensor-proxy-control-plane.sh", r.requireSensorProxyEnabled(r.handleDownloadMigrationScript)) - r.mux.HandleFunc("/api/install/migrate-temperature-proxy.sh", r.requireSensorProxyEnabled(r.handleDownloadTemperatureProxyMigrationScript)) r.mux.HandleFunc("/api/install/install-docker.sh", r.handleDownloadDockerInstallerScript) r.mux.HandleFunc("/api/install/install.sh", r.handleDownloadUnifiedInstallScript) r.mux.HandleFunc("/api/install/install.ps1", r.handleDownloadUnifiedInstallScriptPS) @@ -1200,7 +1186,6 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/system/settings/update", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.systemSettingsHandler.HandleUpdateSystemSettings))) r.mux.HandleFunc("/api/system/ssh-config", r.handleSSHConfig) r.mux.HandleFunc("/api/system/verify-temperature-ssh", r.handleVerifyTemperatureSSH) - r.mux.HandleFunc("/api/system/proxy-public-key", r.requireSensorProxyEnabled(r.handleProxyPublicKey)) // Old API token endpoints removed - now using /api/security/regenerate-token // Agent execution server for AI tool use @@ -1633,49 +1618,6 @@ func (r *Router) handleSSHConfig(w http.ResponseWriter, req *http.Request) { w.Write([]byte(`{"error":"Authentication required"}`)) } -// handleProxyPublicKey returns the temperature proxy's public SSH key (public endpoint) -func (r *Router) handleProxyPublicKey(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - // Try to get the proxy's public key - proxyClient := tempproxy.NewClient() - if !proxyClient.IsAvailable() { - // Proxy not available - return empty response - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusNotFound) - w.Write([]byte("")) - return - } - - // Get proxy status which includes the public key - status, err := proxyClient.GetStatus() - if err != nil { - log.Warn().Err(err).Msg("Failed to get proxy status") - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusServiceUnavailable) - w.Write([]byte("")) - return - } - - // Extract public key - publicKey, ok := status["public_key"].(string) - if !ok || publicKey == "" { - log.Warn().Msg("Public key not found in proxy status") - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusNotFound) - w.Write([]byte("")) - return - } - - // Return the public key as plain text - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - w.Write([]byte(publicKey)) -} - func extractSetupToken(req *http.Request) string { if token := strings.TrimSpace(req.Header.Get("X-Setup-Token")); token != "" { return token @@ -1798,9 +1740,6 @@ func (r *Router) SetConfig(cfg *config.Config) { if r.systemSettingsHandler != nil { r.systemSettingsHandler.SetConfig(r.config) } - if r.temperatureProxyHandlers != nil { - r.temperatureProxyHandlers.SetConfig(r.config) - } } // StartPatrol starts the AI patrol service for background infrastructure monitoring @@ -2595,29 +2534,22 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { "/api/login", // Add login endpoint as public "/api/oidc/login", config.DefaultOIDCCallbackPath, - "/install-docker-agent.sh", // Docker agent bootstrap script must be public - "/install-container-agent.sh", // Container agent bootstrap script must be public - "/download/pulse-docker-agent", // Agent binary download should not require auth - "/install-host-agent.sh", // Host agent bootstrap script must be public - "/install-host-agent.ps1", // Host agent PowerShell script must be public - "/uninstall-host-agent.sh", // Host agent uninstall script must be public - "/uninstall-host-agent.ps1", // Host agent uninstall script must be public - "/download/pulse-host-agent", // Host agent binary download should not require auth - "/install.sh", // Unified agent installer - "/install.ps1", // Unified agent Windows installer - "/download/pulse-agent", // Unified agent binary - "/api/agent/version", // Agent update checks need to work before auth - "/api/agent/ws", // Agent WebSocket has its own auth via registration - "/api/server/info", // Server info for installer script - "/api/install/install-sensor-proxy.sh", // Temperature proxy installer fallback - "/api/install/pulse-sensor-proxy", // Temperature proxy binary fallback - "/api/install/migrate-sensor-proxy-control-plane.sh", // Proxy migration helper - "/api/install/migrate-temperature-proxy.sh", // SSH-to-proxy migration helper - "/api/install/install-docker.sh", // Docker turnkey installer - "/api/system/proxy-public-key", // Temperature proxy public key for setup script - "/api/temperature-proxy/register", // Temperature proxy registration (called by installer) - "/api/temperature-proxy/authorized-nodes", // Proxy control-plane sync - "/api/ai/oauth/callback", // OAuth callback from Anthropic for Claude subscription auth + "/install-docker-agent.sh", // Docker agent bootstrap script must be public + "/install-container-agent.sh", // Container agent bootstrap script must be public + "/download/pulse-docker-agent", // Agent binary download should not require auth + "/install-host-agent.sh", // Host agent bootstrap script must be public + "/install-host-agent.ps1", // Host agent PowerShell script must be public + "/uninstall-host-agent.sh", // Host agent uninstall script must be public + "/uninstall-host-agent.ps1", // Host agent uninstall script must be public + "/download/pulse-host-agent", // Host agent binary download should not require auth + "/install.sh", // Unified agent installer + "/install.ps1", // Unified agent Windows installer + "/download/pulse-agent", // Unified agent binary + "/api/agent/version", // Agent update checks need to work before auth + "/api/agent/ws", // Agent WebSocket has its own auth via registration + "/api/server/info", // Server info for installer script + "/api/install/install-docker.sh", // Docker turnkey installer + "/api/ai/oauth/callback", // OAuth callback from Anthropic for Claude subscription auth } // Also allow static assets without auth (JS, CSS, etc) @@ -5330,45 +5262,6 @@ func (r *Router) serveChecksum(w http.ResponseWriter, filePath string) { fmt.Fprintf(w, "%s\n", checksum) } -func (r *Router) handleDiagnosticsRegisterProxyNodes(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil) - return - } - - client := tempproxy.NewClient() - if client == nil || !client.IsAvailable() { - writeErrorResponse(w, http.StatusServiceUnavailable, "proxy_unavailable", "pulse-sensor-proxy socket not detected inside the container", nil) - return - } - - nodes, err := client.RegisterNodes() - if err != nil { - var proxyErr *tempproxy.ProxyError - if errors.As(err, &proxyErr) { - status := http.StatusBadGateway - code := "proxy_error" - if proxyErr.Type == tempproxy.ErrorTypeAuth { - status = http.StatusForbidden - code = "proxy_permission_denied" - } - writeErrorResponse(w, status, code, proxyErr.Error(), nil) - return - } - - log.Error().Err(err).Msg("Failed to request proxy node registration status") - writeErrorResponse(w, http.StatusBadGateway, "proxy_error", err.Error(), nil) - return - } - - if err := utils.WriteJSONResponse(w, map[string]any{ - "success": true, - "nodes": nodes, - }); err != nil { - log.Error().Err(err).Msg("Failed to encode proxy register nodes response") - } -} - func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost { writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil) @@ -5452,165 +5345,6 @@ func (r *Router) handleDiagnosticsDockerPrepareToken(w http.ResponseWriter, req } } -func (r *Router) handleDownloadPulseSensorProxy(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet && req.Method != http.MethodHead { - writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) - return - } - - // Get requested architecture from query param - arch := strings.TrimSpace(req.URL.Query().Get("arch")) - if arch == "" { - arch = "linux-amd64" // Default to amd64 - } - - var binaryPath string - var filename string - - // Map architecture to binary filename - switch arch { - case "linux-amd64", "amd64": - filename = "pulse-sensor-proxy-linux-amd64" - case "linux-arm64", "arm64": - filename = "pulse-sensor-proxy-linux-arm64" - case "linux-armv7", "armv7", "armhf": - filename = "pulse-sensor-proxy-linux-armv7" - case "linux-armv6", "armv6": - filename = "pulse-sensor-proxy-linux-armv6" - case "linux-386", "386", "i386", "i686": - filename = "pulse-sensor-proxy-linux-386" - default: - writeErrorResponse(w, http.StatusBadRequest, "unsupported_arch", fmt.Sprintf("Unsupported architecture: %s", arch), nil) - return - } - - // Try pre-built architecture-specific binary first (in container) - binaryPath = filepath.Join(pulseBinDir(), filename) - content, err := os.ReadFile(binaryPath) - if err != nil { - // Try generic pulse-sensor-proxy binary (built for host arch) - genericPath := filepath.Join(pulseBinDir(), "pulse-sensor-proxy") - content, err = os.ReadFile(genericPath) - if err == nil { - log.Info(). - Str("arch", arch). - Str("path", genericPath). - Int("size", len(content)). - Msg("Serving generic pulse-sensor-proxy binary (built for host arch)") - binaryPath = genericPath - } - } - - if err != nil { - // Fallback: Try to build on-the-fly for dev environments - log.Info(). - Str("arch", arch). - Str("tried_path", binaryPath). - Msg("Pre-built binary not found, attempting to build on-the-fly (dev mode)") - - if !strings.HasPrefix(arch, "linux-amd64") && runtime.GOARCH != strings.TrimPrefix(arch, "linux-") { - writeErrorResponse(w, http.StatusBadRequest, "cross_compile_unsupported", "Cross-compilation not supported in dev mode", nil) - return - } - - tmpFile, err := os.CreateTemp("", "pulse-sensor-proxy-*.bin") - if err != nil { - log.Error().Err(err).Msg("Failed to create temp file for on-the-fly build") - writeErrorResponse(w, http.StatusInternalServerError, "tempfile_error", "Binary not available and build failed", nil) - return - } - tmpFileName := tmpFile.Name() - tmpFile.Close() - defer os.Remove(tmpFileName) - - // Determine target architecture - targetArch := "amd64" - if strings.Contains(arch, "arm64") { - targetArch = "arm64" - } else if strings.Contains(arch, "arm") { - targetArch = "arm" - } else if strings.Contains(arch, "386") { - targetArch = "386" - } - - ldflags := "-X main.Version=4.32.0-dev" - cmd := exec.Command("go", "build", "-ldflags", ldflags, "-o", tmpFileName, "./cmd/pulse-sensor-proxy") - cmd.Dir = r.projectRoot - cmd.Env = append(os.Environ(), - "CGO_ENABLED=0", - "GOOS=linux", - fmt.Sprintf("GOARCH=%s", targetArch), - ) - - buildOutput, err := cmd.CombinedOutput() - if err != nil { - log.Error().Err(err).Bytes("output", buildOutput).Msg("Failed to build pulse-sensor-proxy binary on-the-fly") - writeErrorResponse(w, http.StatusInternalServerError, "build_failed", "Binary not available and on-the-fly build failed", nil) - return - } - - // Read the built binary - content, err = os.ReadFile(tmpFileName) - if err != nil { - log.Error().Err(err).Msg("Failed to read built binary") - writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read built binary", nil) - return - } - - log.Info(). - Str("arch", arch). - Int("size", len(content)). - Msg("Successfully built pulse-sensor-proxy binary on-the-fly") - } else { - log.Info(). - Str("path", binaryPath). - Str("arch", arch). - Int("size", len(content)). - Msg("Serving pre-built pulse-sensor-proxy binary") - } - - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content))) - - if _, err := w.Write(content); err != nil { - log.Error().Err(err).Msg("Failed to write proxy binary to client") - } -} - -func (r *Router) handleDownloadInstallerScript(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet && req.Method != http.MethodHead { - writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) - return - } - - log.Warn(). - Str("path", req.URL.Path). - Str("remote", req.RemoteAddr). - Msg("Deprecated pulse-sensor-proxy installer requested - use pulse-agent --enable-proxmox instead") - w.Header().Set("Warning", `299 - "pulse-sensor-proxy is deprecated in v5; use pulse-agent --enable-proxmox"`) - - // Try pre-built location first (in container) - scriptPath := "/opt/pulse/scripts/install-sensor-proxy.sh" - content, err := os.ReadFile(scriptPath) - if err != nil { - // Fallback to project root (dev environment) - scriptPath = filepath.Join(r.projectRoot, "scripts", "install-sensor-proxy.sh") - content, err = os.ReadFile(scriptPath) - if err != nil { - log.Error().Err(err).Str("path", scriptPath).Msg("Failed to read installer script") - writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read installer script", nil) - return - } - } - - w.Header().Set("Content-Type", "text/x-shellscript") - w.Header().Set("Content-Disposition", "attachment; filename=install-sensor-proxy.sh") - if _, err := w.Write(content); err != nil { - log.Error().Err(err).Msg("Failed to write installer script to client") - } -} - func (r *Router) handleDownloadDockerInstallerScript(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet && req.Method != http.MethodHead { writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) @@ -5638,141 +5372,6 @@ func (r *Router) handleDownloadDockerInstallerScript(w http.ResponseWriter, req } } -func (r *Router) handleDownloadMigrationScript(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet && req.Method != http.MethodHead { - writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) - return - } - - scriptPath := "/opt/pulse/scripts/migrate-sensor-proxy-control-plane.sh" - content, err := os.ReadFile(scriptPath) - if err != nil { - scriptPath = filepath.Join(r.projectRoot, "scripts", "migrate-sensor-proxy-control-plane.sh") - content, err = os.ReadFile(scriptPath) - if err != nil { - log.Error().Err(err).Str("path", scriptPath).Msg("Failed to read migration script") - writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read migration script", nil) - return - } - } - - w.Header().Set("Content-Type", "text/x-shellscript") - w.Header().Set("Content-Disposition", "attachment; filename=migrate-sensor-proxy-control-plane.sh") - if _, err := w.Write(content); err != nil { - log.Error().Err(err).Msg("Failed to write migration script to client") - } -} - -func (r *Router) handleDownloadTemperatureProxyMigrationScript(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet && req.Method != http.MethodHead { - writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) - return - } - - scriptPath := "/opt/pulse/scripts/migrate-temperature-proxy.sh" - content, err := os.ReadFile(scriptPath) - if err != nil { - scriptPath = filepath.Join(r.projectRoot, "scripts", "migrate-temperature-proxy.sh") - content, err = os.ReadFile(scriptPath) - if err != nil { - log.Error().Err(err).Str("path", scriptPath).Msg("Failed to read temperature migration script") - writeErrorResponse(w, http.StatusInternalServerError, "read_error", "Failed to read temperature migration script", nil) - return - } - } - - w.Header().Set("Content-Type", "text/x-shellscript") - w.Header().Set("Content-Disposition", "attachment; filename=migrate-temperature-proxy.sh") - if _, err := w.Write(content); err != nil { - log.Error().Err(err).Msg("Failed to write temperature migration script to client") - } -} - -func (r *Router) handleTemperatureProxyInstallCommand(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) - return - } - - log.Warn(). - Str("path", req.URL.Path). - Str("remote", req.RemoteAddr). - Msg("Deprecated sensor-proxy install command requested - use pulse-agent --enable-proxmox instead") - w.Header().Set("Warning", `299 - "pulse-sensor-proxy is deprecated in v5; use pulse-agent --enable-proxmox"`) - - baseURL := strings.TrimSpace(r.resolvePublicURL(req)) - if baseURL == "" { - http.Error(w, "Pulse public URL is not configured", http.StatusBadRequest) - return - } - baseURL = strings.TrimRight(baseURL, "/") - - node := strings.TrimSpace(req.URL.Query().Get("node")) - - // Use --ctid approach which works for both local and remote Proxmox hosts. - // The installer detects when the container doesn't exist locally and - // installs in "host monitoring only" mode. This is more reliable than - // --standalone --http-mode which is meant for Docker deployments. - ctid := "" - if summary, err := loadHostProxySummary(); err == nil && summary != nil && summary.CTID != "" { - ctid = summary.CTID - } - - command := fmt.Sprintf( - "curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install-sensor-proxy.sh | sudo bash -s -- --ctid %s --pulse-server %s", - ctid, baseURL, - ) - - response := map[string]string{ - "command": command, - "pulseURL": baseURL, - } - if node != "" { - response["node"] = node - } - - if err := utils.WriteJSONResponse(w, response); err != nil { - log.Error().Err(err).Msg("Failed to serialize proxy install command response") - } -} - -func (r *Router) handleHostProxyStatus(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodGet { - writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) - return - } - - hostSocket := fileExists("/run/pulse-sensor-proxy/pulse-sensor-proxy.sock") - containerSocket := fileExists("/mnt/pulse-proxy/pulse-sensor-proxy.sock") - - resp := map[string]interface{}{ - "hostSocketPresent": hostSocket, - "containerSocketPresent": containerSocket, - "lastChecked": time.Now().UTC().Format(time.RFC3339), - } - - if summary, err := loadHostProxySummary(); err == nil && summary != nil { - resp["summary"] = summary - } - - baseURL := strings.TrimRight(r.resolvePublicURL(req), "/") - if baseURL == "" { - baseURL = "http://localhost:7655" - } - - ctid := "" - if summary, ok := resp["summary"].(*HostProxySummary); ok && summary != nil && summary.CTID != "" { - ctid = summary.CTID - } - - resp["reinstallCommand"] = fmt.Sprintf("curl -fsSL https://github.com/rcourtman/Pulse/releases/latest/download/install-sensor-proxy.sh | sudo bash -s -- --ctid %s --pulse-server %s", ctid, baseURL) - resp["installerURL"] = fmt.Sprintf("%s/api/install/install-sensor-proxy.sh", baseURL) - - if err := utils.WriteJSONResponse(w, resp); err != nil { - log.Error().Err(err).Msg("Failed to serialize host proxy status response") - } -} - func (r *Router) resolvePublicURL(req *http.Request) string { if agentConnectURL := strings.TrimSpace(r.config.AgentConnectURL); agentConnectURL != "" { return strings.TrimRight(agentConnectURL, "/")