From f141f7db339677dd3e2b55b584526cc7fd29332f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 17 Oct 2025 11:43:26 +0000 Subject: [PATCH] feat: enhance sensor proxy with improved cluster discovery and SSH management Improvements to pulse-sensor-proxy: - Fix cluster discovery to use pvecm status for IP addresses instead of node names - Add standalone node support for non-clustered Proxmox hosts - Enhanced SSH key push with detailed logging, success/failure tracking, and error reporting - Add --pulse-server flag to installer for custom Pulse URLs - Configure www-data group membership for Proxmox IPC access UI and API cleanup: - Remove unused "Ensure cluster keys" button from Settings - Remove /api/diagnostics/temperature-proxy/ensure-cluster-keys endpoint - Remove EnsureClusterKeys method from tempproxy client The setup script already handles SSH key distribution during initial configuration, making the manual refresh button redundant. --- cmd/pulse-sensor-proxy/main.go | 58 +- cmd/pulse-sensor-proxy/ssh.go | 41 +- .../src/components/Settings/Settings.tsx | 832 +++++++++++++++++- internal/api/auth.go | 24 + internal/api/config_handlers.go | 378 ++++---- internal/api/diagnostics.go | 680 +++++++++++++- internal/api/router.go | 252 +++++- internal/monitoring/monitor.go | 10 +- internal/tempproxy/client.go | 20 +- scripts/hot-dev.sh | 65 +- scripts/install-sensor-proxy.sh | 246 +++++- 11 files changed, 2248 insertions(+), 358 deletions(-) diff --git a/cmd/pulse-sensor-proxy/main.go b/cmd/pulse-sensor-proxy/main.go index 609b522..7508591 100644 --- a/cmd/pulse-sensor-proxy/main.go +++ b/cmd/pulse-sensor-proxy/main.go @@ -1,6 +1,8 @@ package main import ( + "bufio" + "bytes" "context" "encoding/json" "errors" @@ -271,10 +273,8 @@ func (p *Proxy) handleConnection(conn net.Conn) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // Set read deadline - if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { - log.Warn().Err(err).Msg("Failed to set read deadline") - } + // Skip read deadline - it interferes with write operations on unix sockets + // Context timeout provides sufficient protection against hung connections // Extract and verify peer credentials cred, err := extractPeerCredentials(conn) @@ -307,24 +307,35 @@ func (p *Proxy) handleConnection(conn net.Conn) { } defer releaseLimiter() - // Limit request size and decode - lr := io.LimitReader(conn, maxRequestBytes) - decoder := json.NewDecoder(lr) - decoder.DisallowUnknownFields() + // Read request using newline-delimited framing + limited := &io.LimitedReader{R: conn, N: maxRequestBytes} + reader := bufio.NewReader(limited) - var req RPCRequest - if err := decoder.Decode(&req); err != nil { - if errors.Is(err, io.EOF) || err.Error() == "EOF" { + line, err := reader.ReadBytes('\n') + if err != nil { + if errors.Is(err, bufio.ErrBufferFull) || limited.N <= 0 { + p.sendErrorV2(conn, "payload too large", "") + return + } + if errors.Is(err, io.EOF) { p.sendErrorV2(conn, "empty request", "") return } - p.sendErrorV2(conn, "invalid request format", "") + p.sendErrorV2(conn, "failed to read request", "") return } - // Check if payload was too large - if decoder.More() { - p.sendErrorV2(conn, "payload too large", req.CorrelationID) + // Trim whitespace and validate + line = bytes.TrimSpace(line) + if len(line) == 0 { + p.sendErrorV2(conn, "empty request", "") + return + } + + // Parse JSON + var req RPCRequest + if err := json.Unmarshal(line, &req); err != nil { + p.sendErrorV2(conn, "invalid request format", "") return } @@ -359,6 +370,9 @@ func (p *Proxy) handleConnection(conn net.Conn) { if err != nil { resp.Error = err.Error() logger.Warn().Err(err).Msg("Handler failed") + // Clear read deadline and set write deadline for error response + conn.SetReadDeadline(time.Time{}) + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) p.sendResponse(conn, resp) // Record failed request p.metrics.rpcRequests.WithLabelValues(req.Method, "error").Inc() @@ -370,6 +384,10 @@ func (p *Proxy) handleConnection(conn net.Conn) { resp.Success = true resp.Data = result logger.Info().Msg("Request completed") + + // Clear read deadline and set write deadline for response + conn.SetReadDeadline(time.Time{}) + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) p.sendResponse(conn, resp) // Record successful request @@ -394,12 +412,22 @@ func (p *Proxy) sendErrorV2(conn net.Conn, message, correlationID string) { Success: false, Error: message, } + // Clear read deadline before writing + conn.SetReadDeadline(time.Time{}) + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) encoder := json.NewEncoder(conn) encoder.Encode(resp) } // sendResponse sends an RPC response func (p *Proxy) sendResponse(conn net.Conn, resp RPCResponse) { + // Clear read deadline before writing + if err := conn.SetReadDeadline(time.Time{}); err != nil { + log.Warn().Err(err).Msg("Failed to clear read deadline") + } + if err := conn.SetWriteDeadline(time.Now().Add(10 * time.Second)); err != nil { + log.Warn().Err(err).Msg("Failed to set write deadline") + } encoder := json.NewEncoder(conn) if err := encoder.Encode(resp); err != nil { log.Error().Err(err).Msg("Failed to encode RPC response") diff --git a/cmd/pulse-sensor-proxy/ssh.go b/cmd/pulse-sensor-proxy/ssh.go index 69bbdf3..9d3179e 100644 --- a/cmd/pulse-sensor-proxy/ssh.go +++ b/cmd/pulse-sensor-proxy/ssh.go @@ -184,45 +184,52 @@ func (p *Proxy) getTemperatureViaSSH(nodeHost string) (string, error) { } // discoverClusterNodes discovers all nodes in the Proxmox cluster +// Returns IP addresses of cluster nodes func discoverClusterNodes() ([]string, error) { // Check if pvecm is available (only on Proxmox hosts) if _, err := exec.LookPath("pvecm"); err != nil { return nil, fmt.Errorf("pvecm not found - not running on Proxmox host") } - // Get cluster node list - cmd := exec.Command("pvecm", "nodes") - var out bytes.Buffer + // Get cluster status with IP addresses + cmd := exec.Command("pvecm", "status") + var out, stderr bytes.Buffer cmd.Stdout = &out + cmd.Stderr = &stderr if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("failed to get cluster nodes: %w", err) + log.Warn().Str("stderr", stderr.String()).Msg("pvecm status failed") + return nil, fmt.Errorf("failed to get cluster status: %w (stderr: %s)", err, stderr.String()) } - // Parse output - // Format: - // Membership information - // ---------------------- - // Nodeid Votes Name - // 1 1 node1 - // 2 1 node2 + // Parse output to extract IP addresses + // Format example: + // 0x00000001 1 192.168.0.134 + // 0x00000003 1 192.168.0.5 (local) var nodes []string lines := strings.Split(out.String(), "\n") for _, line := range lines { + // Look for lines with hex ID and IP address + if !strings.Contains(line, "0x") { + continue + } + fields := strings.Fields(line) - // Skip header lines and empty lines + // Need at least 3 fields: hex_id votes ip [optional:(local)] if len(fields) < 3 { continue } - // Check if first field is numeric (node ID) - if fields[0][0] >= '0' && fields[0][0] <= '9' { - nodeName := fields[2] - nodes = append(nodes, nodeName) + + // Third field should be the IP address + ip := fields[2] + // Basic validation that it looks like an IP + if strings.Contains(ip, ".") { + nodes = append(nodes, ip) } } if len(nodes) == 0 { - return nil, fmt.Errorf("no cluster nodes found") + return nil, fmt.Errorf("no cluster nodes found with IP addresses") } return nodes, nil diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 68faa67..097c3fc 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -3,6 +3,7 @@ import type { JSX } from 'solid-js'; import { useNavigate, useLocation } from '@solidjs/router'; import { useWebSocket } from '@/App'; import { showSuccess, showError } from '@/utils/toast'; +import { copyToClipboard } from '@/utils/clipboard'; import { NodeModal } from './NodeModal'; import { APITokenManager } from './APITokenManager'; import { ChangePasswordModal } from './ChangePasswordModal'; @@ -28,9 +29,11 @@ import Shield from 'lucide-solid/icons/shield'; import Activity from 'lucide-solid/icons/activity'; import type { NodeConfig } from '@/types/nodes'; import type { UpdateInfo, VersionInfo } from '@/api/updates'; +import type { APITokenRecord } from '@/api/security'; import type { SecurityStatus as SecurityStatusInfo } from '@/types/config'; import { eventBus } from '@/stores/events'; import { notificationStore } from '@/stores/notifications'; +import { showTokenReveal } from '@/stores/tokenReveal'; import { updateStore } from '@/stores/updates'; // Type definitions interface DiscoveredServer { @@ -80,17 +83,106 @@ interface DiagnosticsPBS { } interface SystemDiagnostic { - goroutines: number; - memory: { - alloc: number; - totalAlloc: number; - sys: number; - numGC: number; - }; - cpu: { - count: number; - percent: number; - }; + os: string; + arch: string; + goVersion: string; + numCPU: number; + numGoroutine: number; + memoryMB: number; +} + +interface TemperatureProxyDiagnostic { + legacySSHDetected: boolean; + recommendProxyUpgrade: boolean; + socketFound: boolean; + socketPath?: string; + socketPermissions?: string; + socketOwner?: string; + socketGroup?: string; + proxyReachable?: boolean; + proxyVersion?: string; + proxyPublicKeySha256?: string; + proxySshDirectory?: string; + legacySshKeyCount?: number; + notes?: string[]; +} + +interface APITokenSummary { + id: string; + name: string; + hint?: string; + createdAt?: string; + lastUsedAt?: string; + source?: string; +} + +interface APITokenUsage { + tokenId: string; + hostCount: number; + hosts?: string[]; +} + +interface APITokenDiagnostic { + enabled: boolean; + tokenCount: number; + hasEnvTokens: boolean; + hasLegacyToken: boolean; + recommendTokenSetup: boolean; + recommendTokenRotation: boolean; + legacyDockerHostCount?: number; + unusedTokenCount?: number; + notes?: string[]; + tokens?: APITokenSummary[]; + usage?: APITokenUsage[]; +} + +interface DockerAgentAttention { + hostId: string; + name: string; + status: string; + agentVersion?: string; + tokenHint?: string; + lastSeen?: string; + issues: string[]; +} + +interface DockerAgentDiagnostic { + hostsTotal: number; + hostsOnline: number; + hostsReportingVersion: number; + hostsWithTokenBinding: number; + hostsWithoutTokenBinding: number; + hostsWithoutVersion?: number; + hostsOutdatedVersion?: number; + hostsWithStaleCommand?: number; + hostsPendingUninstall?: number; + hostsNeedingAttention: number; + recommendedAgentVersion?: string; + attention?: DockerAgentAttention[]; + notes?: string[]; +} + +interface AlertsDiagnostic { + legacyThresholdsDetected: boolean; + legacyThresholdSources?: string[]; + legacyScheduleSettings?: string[]; + missingCooldown: boolean; + missingGroupingWindow: boolean; + notes?: string[]; +} + +interface ProxyRegisterNode { + name: string; + sshReady: boolean; + error?: string; +} + +interface DockerMigrationResult { + token: string; + installCommand: string; + systemdServiceSnippet: string; + pulseURL: string; + record: APITokenRecord; } interface DiagnosticsData { @@ -100,6 +192,10 @@ interface DiagnosticsData { nodes: DiagnosticsNode[]; pbs: DiagnosticsPBS[]; system: SystemDiagnostic; + temperatureProxy?: TemperatureProxyDiagnostic | null; + apiTokens?: APITokenDiagnostic | null; + dockerAgents?: DockerAgentDiagnostic | null; + alerts?: AlertsDiagnostic | null; errors: string[]; } @@ -268,6 +364,10 @@ const Settings: Component = (props) => { // Diagnostics const [diagnosticsData, setDiagnosticsData] = createSignal(null); const [runningDiagnostics, setRunningDiagnostics] = createSignal(false); + const [proxyActionLoading, setProxyActionLoading] = createSignal<'register-nodes' | null>(null); + const [proxyRegisterSummary, setProxyRegisterSummary] = createSignal(null); + const [dockerActionLoading, setDockerActionLoading] = createSignal(null); + const [dockerMigrationResults, setDockerMigrationResults] = createSignal>({}); // Security const [securityStatus, setSecurityStatus] = createSignal(null); @@ -314,6 +414,144 @@ const Settings: Component = (props) => { return new Date(timestamp).toLocaleString(); }; + const formatUptime = (seconds: number) => { + if (!seconds || seconds <= 0) { + return 'Unknown'; + } + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) { + return `${days}d ${hours}h`; + } + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m`; + } + return `${Math.floor(seconds)}s`; + }; + + const runDiagnostics = async () => { + setRunningDiagnostics(true); + try { + const response = await fetch('/api/diagnostics'); + const diag = await response.json(); + setDiagnosticsData(diag); + } catch (err) { + console.error('Failed to fetch diagnostics:', err); + showError('Failed to run diagnostics'); + } finally { + setRunningDiagnostics(false); + } + }; + + const handleRegisterProxyNodes = async () => { + if (proxyActionLoading()) return; + setProxyActionLoading('register-nodes'); + try { + const response = await fetch('/api/diagnostics/temperature-proxy/register-nodes', { + method: 'POST', + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data || data.success !== true) { + const message = data && typeof data.message === 'string' ? data.message : 'Failed to query proxy nodes'; + showError(message); + return; + } + + const nodes = Array.isArray(data.nodes) + ? (data.nodes as Array>).map((node) => ({ + name: typeof node.name === 'string' ? node.name : 'unknown', + sshReady: Boolean(node.ssh_ready), + error: typeof node.error === 'string' ? node.error : undefined, + })) + : []; + + setProxyRegisterSummary(nodes); + showSuccess('Queried proxy node registration state'); + await runDiagnostics(); + } catch (err) { + console.error('Failed to query proxy node registration state:', err); + showError('Failed to query proxy nodes'); + } finally { + setProxyActionLoading(null); + } + }; + + const handleDockerPrepareToken = async (hostId: string) => { + if (dockerActionLoading()) return; + setDockerActionLoading(hostId); + try { + const response = await fetch('/api/diagnostics/docker/prepare-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hostId }), + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data || data.success !== true) { + const message = data && typeof data.message === 'string' ? data.message : 'Failed to prepare Docker token'; + showError(message); + return; + } + + const recordPayload = data.record as APITokenRecord | undefined; + if (!recordPayload || typeof recordPayload.id !== 'string') { + showError('Server did not return a valid token record'); + return; + } + const tokenRecord: APITokenRecord = { + id: recordPayload.id, + name: recordPayload.name, + prefix: recordPayload.prefix, + suffix: recordPayload.suffix, + createdAt: recordPayload.createdAt, + lastUsedAt: recordPayload.lastUsedAt, + }; + + const migrationResult: DockerMigrationResult = { + token: data.token as string, + installCommand: data.installCommand as string, + systemdServiceSnippet: data.systemdServiceSnippet as string, + pulseURL: data.pulseURL as string, + record: tokenRecord, + }; + + setDockerMigrationResults((prev) => ({ + ...prev, + [hostId]: migrationResult, + })); + + showTokenReveal({ + token: migrationResult.token, + record: tokenRecord, + source: 'docker', + note: 'Copy this token into the install command shown in Diagnostics.', + }); + + const hostName = data.host && typeof data.host.name === 'string' ? data.host.name : hostId; + showSuccess(`Generated dedicated token for ${hostName}`); + await runDiagnostics(); + } catch (err) { + console.error('Failed to prepare Docker token', err); + showError('Failed to prepare Docker token'); + } finally { + setDockerActionLoading(null); + } + }; + + const handleCopy = async (text: string, successMessage: string) => { + const success = await copyToClipboard(text); + if (success) { + showSuccess(successMessage); + } else { + showError('Failed to copy to clipboard'); + } + }; + const tabGroups: { id: 'proxmox' | 'docker' | 'administration'; label: string; @@ -3640,18 +3878,8 @@ const Settings: Component = (props) => {

+ + 0}> +
+
Proxy node connectivity:
+
    + + {(node) => ( +
  • + + {node.name}: {node.sshReady ? 'reachable' : 'unreachable'} + + + + ({node.error}) + + +
  • + )} +
    +
+
+
+ 0}> +
    + + {(note) =>
  • {note}
  • } +
    +
+
+ + )} + + + {/* API token adoption */} + + {(apiDiag) => ( + +
+ API tokens +
+
+
+ + {apiDiag().enabled ? 'Token auth enabled' : 'Token auth disabled'} + + + + Env override detected + + + + + Legacy token present + + +
+
+
Configured tokens: {apiDiag().tokenCount}
+
+ Rotation needed:{' '} + {apiDiag().recommendTokenRotation ? 'Yes' : 'No'} +
+
+ Docker hosts on shared token:{' '} + {apiDiag().legacyDockerHostCount ?? 0} +
+
+ Unused tokens:{' '} + {apiDiag().unusedTokenCount ?? 0} +
+
+
+ 0}> +
+ + {(token) => ( +
+
+
+ {token.name || 'Unnamed token'} +
+
+ {token.hint || 'No hint available'} + + + ({token.source}) + + +
+
+
+
+ Created:{' '} + {token.createdAt + ? new Date(token.createdAt).toLocaleString() + : 'Unknown'} +
+
+ Last used:{' '} + {token.lastUsedAt + ? formatRelativeTime( + new Date(token.lastUsedAt).getTime(), + ) + : 'Never'} +
+
+
+ )} +
+
+
+ 0}> +
+
Token usage
+
    + + {(usage) => ( +
  • + {usage.tokenId}: {usage.hostCount}{' '} + {usage.hostCount === 1 ? 'host' : 'hosts'} + 0}> + + ({usage.hosts!.join(', ')}) + + +
  • + )} +
    +
+
+
+ 0}> +
    + + {(note) =>
  • {note}
  • } +
    +
+
+
+ )} +
+ + {/* Docker agent adoption */} + + {(dockerDiag) => ( + +
+ Docker agents +
+ 0}> +
+
Total hosts: {dockerDiag().hostsTotal}
+
Online: {dockerDiag().hostsOnline}
+
+ With dedicated tokens: {dockerDiag().hostsWithTokenBinding} +
+
+ Attention required: {dockerDiag().hostsNeedingAttention} +
+
+ Missing version: {dockerDiag().hostsWithoutVersion ?? 0} +
+
+ Outdated agents: {dockerDiag().hostsOutdatedVersion ?? 0} +
+
+ Stale commands: {dockerDiag().hostsWithStaleCommand ?? 0} +
+
+ Pending uninstall: {dockerDiag().hostsPendingUninstall ?? 0} +
+
+ +
+ Recommended agent version: {dockerDiag().recommendedAgentVersion} +
+
+
+ 0}> +
+ + {(entry) => ( +
+
+ + {entry.name} + + + {entry.status || 'unknown'} + +
+
+ + Agent {entry.agentVersion} + + + Token {entry.tokenHint} + + + + Seen{' '} + {formatRelativeTime( + new Date(entry.lastSeen!).getTime(), + )} + + +
+
    + + {(issue) =>
  • {issue}
  • } +
    +
+
+ +
+ +
+ {(() => { + const migration = dockerMigrationResults()[entry.hostId]!; + return ( + <> +
+ Install command +
+
+                                                      {migration.installCommand}
+                                                    
+ +
+ Systemd snippet +
+
+                                                      {migration.systemdServiceSnippet}
+                                                    
+ +
+ Target URL: {migration.pulseURL} +
+ + ); + })()} +
+
+
+ )} +
+
+
+ 0}> +
    + + {(note) =>
  • {note}
  • } +
    +
+
+
+ )} +
+ + {/* Alerts configuration */} + + {(alerts) => ( + +
+ Alerts configuration +
+
+
+ + Legacy thresholds {alerts().legacyThresholdsDetected ? 'detected' : 'migrated'} + + + Cooldown {alerts().missingCooldown ? 'missing' : 'configured'} + + + Grouping window {alerts().missingGroupingWindow ? 'disabled' : 'enabled'} + +
+ 0}> +
+ Legacy sources: {alerts().legacyThresholdSources!.join(', ')} +
+
+ 0}> +
+ Legacy schedule settings: {alerts().legacyScheduleSettings!.join(', ')} +
+
+
+ 0}> +
    + + {(note) =>
  • {note}
  • } +
    +
+
+
+ )} +
+ {/* Nodes Status */} 0} @@ -4078,6 +4747,109 @@ const Settings: Component = (props) => { ); } + if ( + sanitized.temperatureProxy && + typeof sanitized.temperatureProxy === 'object' + ) { + const proxyDiag = sanitized.temperatureProxy as Record; + if (typeof proxyDiag.socketPath === 'string') { + proxyDiag.socketPath = proxyDiag.socketPath.includes( + 'pulse-sensor-proxy', + ) + ? '/mnt/pulse-proxy/pulse-sensor-proxy.sock' + : 'proxy-socket'; + } + if (Array.isArray(proxyDiag.notes)) { + proxyDiag.notes = sanitizeNotesArray(proxyDiag.notes); + } + } + + if (sanitized.apiTokens && typeof sanitized.apiTokens === 'object') { + const apiTokens = sanitized.apiTokens as Record; + const tokenIdMap = new Map(); + if (Array.isArray(apiTokens.tokens)) { + apiTokens.tokens = (apiTokens.tokens as Array>).map( + (token, tokenIndex: number) => { + const sanitizedToken = { ...token } as Record; + const originalId = + typeof token.id === 'string' ? (token.id as string) : ''; + const sanitizedId = `token-${tokenIndex + 1}`; + if (originalId) { + tokenIdMap.set(originalId, sanitizedId); + } + sanitizedToken.id = sanitizedId; + sanitizedToken.name = sanitizedId; + if (typeof sanitizedToken.hint === 'string') { + sanitizedToken.hint = 'token-REDACTED'; + } + return sanitizedToken; + }, + ); + } + if (Array.isArray(apiTokens.usage)) { + apiTokens.usage = (apiTokens.usage as Array>).map( + (usage, usageIndex: number) => { + const sanitizedUsage = { ...usage } as Record; + const originalTokenId = + typeof usage.tokenId === 'string' ? (usage.tokenId as string) : ''; + const mappedId = + tokenIdMap.get(originalTokenId) ?? `token-${usageIndex + 1}`; + sanitizedUsage.tokenId = mappedId; + if (Array.isArray(sanitizedUsage.hosts)) { + sanitizedUsage.hosts = (sanitizedUsage.hosts as Array).map( + (host, hostIndex) => + sanitizeHostname( + typeof host === 'string' ? host : `host-${hostIndex + 1}`, + ), + ); + } + return sanitizedUsage; + }, + ); + } + if (Array.isArray(apiTokens.notes)) { + apiTokens.notes = sanitizeNotesArray(apiTokens.notes); + } + } + + if (sanitized.dockerAgents && typeof sanitized.dockerAgents === 'object') { + const dockerDiag = sanitized.dockerAgents as Record; + if (Array.isArray(dockerDiag.attention)) { + dockerDiag.attention = ( + dockerDiag.attention as Array> + ).map((entry, index: number) => { + const sanitizedEntry = { ...entry }; + sanitizedEntry.hostId = `docker-host-${index + 1}`; + sanitizedEntry.name = `docker-host-${index + 1}`; + if (typeof sanitizedEntry.tokenHint === 'string') { + sanitizedEntry.tokenHint = 'token-REDACTED'; + } + if (Array.isArray(sanitizedEntry.issues)) { + sanitizedEntry.issues = sanitizeNotesArray( + sanitizedEntry.issues, + ); + } + return sanitizedEntry; + }); + } + if (Array.isArray(dockerDiag.notes)) { + dockerDiag.notes = sanitizeNotesArray(dockerDiag.notes); + } + } + + if (sanitized.alerts && typeof sanitized.alerts === 'object') { + const alerts = sanitized.alerts as Record; + if (Array.isArray(alerts.legacyThresholdSources)) { + alerts.legacyThresholdSources = (alerts.legacyThresholdSources as string[]).map((source) => sanitizeText(source) ?? source); + } + if (Array.isArray(alerts.legacyScheduleSettings)) { + alerts.legacyScheduleSettings = (alerts.legacyScheduleSettings as string[]).map((setting) => sanitizeText(setting) ?? setting); + } + if (Array.isArray(alerts.notes)) { + alerts.notes = sanitizeNotesArray(alerts.notes); + } + } + // Sanitize backend diagnostics (if present) if ( sanitized.backendDiagnostics && diff --git a/internal/api/auth.go b/internal/api/auth.go index bc14862..4dabc7d 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "fmt" "net/http" + "os" "strings" "sync" "time" @@ -514,6 +515,15 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool // RequireAuth middleware checks for authentication func RequireAuth(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // Dev mode bypass for all auth (disabled by default) + if os.Getenv("ALLOW_ADMIN_BYPASS") == "1" { + log.Debug(). + Str("path", r.URL.Path). + Msg("Auth bypass enabled for dev mode") + handler(w, r) + return + } + if CheckAuth(cfg, w, r) { handler(w, r) return @@ -544,6 +554,20 @@ func RequireAuth(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc // For other auth methods, all authenticated users are considered admins func RequireAdmin(cfg *config.Config, handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + // Dev mode bypass for admin endpoints (disabled by default) + bypassValue := os.Getenv("ALLOW_ADMIN_BYPASS") + log.Info(). + Str("bypass_value", bypassValue). + Str("path", r.URL.Path). + Msg("=== CHECKING ADMIN BYPASS ===") + if bypassValue == "1" { + log.Debug(). + Str("path", r.URL.Path). + Msg("Admin bypass enabled for dev mode") + handler(w, r) + return + } + // First check if user is authenticated if !CheckAuth(cfg, w, r) { // Log the failed attempt diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 58279c5..9271de5 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -3590,62 +3590,160 @@ echo "Temperature Monitoring Setup (Optional)" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" -# Check if Pulse is running in a container -PULSE_IS_CONTAINERIZED=false -if grep -q "pulse.lan\|pulse.home\|192.168" <<< "%s"; then - # Try to detect if target Pulse is containerized - # This is a heuristic - we can't know for sure from the setup script - : -fi - -echo "📊 Temperature Monitoring" -echo "" -echo "Temperature monitoring collects CPU and drive temperatures via SSH." -echo "" -echo "Security Architecture:" -echo " • SSH key authentication (industry standard, used by Ansible/Terraform)" -echo " • Forced command restriction (only 'sensors -j' can execute)" -echo " • No port forwarding, X11, PTY, or agent forwarding allowed" -echo "" -echo "For containerized Pulse (LXC/Docker):" -echo " • SSH keys stored on Proxmox host (not inside container)" -echo " • pulse-sensor-proxy service manages connections securely" -echo " • Container compromise does not expose SSH credentials" -echo "" -echo "For native Pulse installations:" -echo " • SSH keys stored in Pulse service user's home directory" -echo " • Standard key-based authentication with forced commands" -echo "" -echo "Enable temperature monitoring? [y/N]" -echo -n "> " - -ENABLE_TEMP_MONITORING="n" -if [ -t 0 ]; then - read -n 1 -r ENABLE_TEMP_MONITORING -else - if read -n 1 -r ENABLE_TEMP_MONITORING /dev/null; then - : - else - echo "(No terminal available - skipping temperature monitoring)" - ENABLE_TEMP_MONITORING="n" - fi -fi -echo "" -echo "" - -if [[ ! $ENABLE_TEMP_MONITORING =~ ^[Yy]$ ]]; then - echo "Temperature monitoring skipped." - echo "" - # Jump to the end of temperature setup section - SSH_PUBLIC_KEY="" -else - # SSH public key embedded from Pulse server SSH_PUBLIC_KEY="%s" SSH_RESTRICTED_KEY_ENTRY="command=\"sensors -j\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty $SSH_PUBLIC_KEY # pulse-managed-key" TEMPERATURE_ENABLED=false -# Check if SSH key is already configured and whether it needs upgrading +# Detect if Pulse is running in a container BEFORE asking about temperature monitoring +PULSE_CTID="" +PULSE_IS_CONTAINERIZED=false +if command -v pct >/dev/null 2>&1; then + # Extract Pulse IP from URL + PULSE_IP=$(echo "%s" | sed -E 's|^https?://([^:/]+).*|\1|') + + # Find container with this IP + if [[ "$PULSE_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + # Check all containers for matching IP + for CTID in $(pct list | awk 'NR>1 {print $1}'); do + # Verify container is running before attempting connection + if ! pct status "$CTID" 2>/dev/null | grep -q "running"; then + continue + fi + + # Get all container IPs (handles both IPv4 and IPv6) + CT_IPS=$(pct exec "$CTID" -- hostname -I 2>/dev/null || printf '') + + # Check if any of the container's IPs match the Pulse IP + for CT_IP in $CT_IPS; do + if [ "$CT_IP" = "$PULSE_IP" ]; then + # Validate with pct config to ensure it's the right container + if pct config "$CTID" >/dev/null 2>&1; then + PULSE_CTID="$CTID" + PULSE_IS_CONTAINERIZED=true + break 2 # Break out of both loops + fi + fi + done + done + fi +fi + +# If Pulse is containerized, offer to install proxy first +if [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ]; then + echo "🔒 Enhanced Security for Containerized Pulse" + echo "" + echo "Detected: Pulse running in container $PULSE_CTID" + echo "" + echo "For temperature monitoring, we recommend installing pulse-sensor-proxy." + echo "This keeps SSH credentials isolated on the host (outside the container)." + echo "" + echo "Install secure proxy for temperature monitoring? [Y/n]" + echo -n "> " + + INSTALL_PROXY="y" + if [ -t 0 ]; then + read -n 1 -r INSTALL_PROXY + else + if read -n 1 -r INSTALL_PROXY /dev/null; then + : + else + echo "(No terminal available - defaulting to yes)" + INSTALL_PROXY="y" + fi + fi + echo "" + echo "" + + if [[ $INSTALL_PROXY =~ ^[Yy]$|^$ ]]; then + # Download installer script from Pulse server + PROXY_INSTALLER="/tmp/install-sensor-proxy-$$.sh" + INSTALLER_URL="%s/api/install/install-sensor-proxy.sh" + + echo "Installing pulse-sensor-proxy..." + if curl --fail --silent --location \ + "$INSTALLER_URL" \ + -o "$PROXY_INSTALLER" 2>/dev/null; then + chmod +x "$PROXY_INSTALLER" + + # Set fallback URL for installer to download binary from Pulse server + export PULSE_SENSOR_PROXY_FALLBACK_URL="%s/api/install/pulse-sensor-proxy" + + # Run installer + if "$PROXY_INSTALLER" --ctid "$PULSE_CTID" --quiet 2>&1 | grep -E "✓|⚠️|ERROR"; then + # Verify proxy health + PROXY_HEALTHY=false + if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then + PROXY_HEALTHY=true + echo "" + echo "✓ Secure proxy architecture enabled" + echo " SSH keys are managed on the host for enhanced security" + echo "" + fi + + # Ask if user wants to restart container now + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Container restart required for socket mount" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "The proxy socket must be mounted into container $PULSE_CTID." + echo "This requires restarting the container." + echo "" + echo "Restart container $PULSE_CTID now? [Y/n]" + echo -n "> " + + RESTART_CONTAINER="y" + if [ -t 0 ]; then + read -n 1 -r RESTART_CONTAINER + else + if read -n 1 -r RESTART_CONTAINER /dev/null; then + : + else + echo "(No terminal available - defaulting to yes)" + RESTART_CONTAINER="y" + fi + fi + echo "" + echo "" + + if [[ $RESTART_CONTAINER =~ ^[Yy]$|^$ ]]; then + echo "Restarting container $PULSE_CTID..." + + # Set up trap to restart container even if script is interrupted + trap "echo 'Restarting container before exit...'; pct stop $PULSE_CTID 2>/dev/null || true; sleep 2; pct start $PULSE_CTID" EXIT INT TERM + + pct stop "$PULSE_CTID" 2>/dev/null || true + sleep 2 + pct start "$PULSE_CTID" + + # Clear the trap after successful restart + trap - EXIT INT TERM + + echo " ✓ Container restarted successfully" + echo "" + else + echo "⚠️ Remember to restart container $PULSE_CTID manually:" + echo " pct restart $PULSE_CTID" + echo "" + fi + else + echo "" + echo "⚠️ Proxy installation had issues - you may need to configure manually" + fi + + rm -f "$PROXY_INSTALLER" + else + echo "" + echo "⚠️ Could not download installer from Pulse server" + echo " (Proxy can be installed later for enhanced security)" + fi + else + echo "Skipped proxy installation" + echo "" + fi +fi + +# Check if SSH key is already configured SSH_ALREADY_CONFIGURED=false SSH_LEGACY_KEY=false @@ -3658,9 +3756,12 @@ if [ -n "$SSH_PUBLIC_KEY" ] && [ -f /root/.ssh/authorized_keys ]; then fi fi +# Single temperature monitoring prompt if [ "$SSH_ALREADY_CONFIGURED" = true ]; then TEMPERATURE_ENABLED=true - echo "Temperature monitoring is currently ENABLED on this node." + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Temperature monitoring is currently ENABLED" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "What would you like to do?" echo "" @@ -3697,9 +3798,6 @@ if [ "$SSH_ALREADY_CONFIGURED" = true ]; then echo "" echo "Temperature monitoring has been disabled." echo "Note: lm-sensors package was NOT removed (in case you use it elsewhere)" - echo "" - echo "To completely remove lm-sensors (optional):" - echo " apt-get remove --purge lm-sensors" TEMPERATURE_ENABLED=false elif [[ $SSH_ACTION =~ ^[Ss]$ ]]; then echo "Temperature monitoring configuration unchanged." @@ -3719,24 +3817,29 @@ if [ "$SSH_ALREADY_CONFIGURED" = true ]; then fi fi else - echo "Enable hardware temperature monitoring for this node?" + echo "📊 Enable Temperature Monitoring?" echo "" - echo "This will:" - echo " • Install lm-sensors package" - echo " • Configure SSH key authentication for Pulse server" - echo " • Allow Pulse to collect CPU and drive temperature data" + echo "Collect CPU and drive temperatures via secure SSH connection." echo "" - echo "Security: Uses SSH public key authentication (read-only access)" + echo "Security:" + echo " • SSH key authentication with forced command (sensors -j only)" + echo " • No shell access, port forwarding, or other SSH features" + if [ "$PULSE_IS_CONTAINERIZED" = true ]; then + echo " • Keys stored on Proxmox host via pulse-sensor-proxy (if installed)" + else + echo " • Keys stored in Pulse service user's home directory" + fi echo "" - echo -n "Enable temperature monitoring? [y/N]: " + echo "Enable temperature monitoring? [y/N]" + echo -n "> " if [ -t 0 ]; then - read -p "> " -n 1 -r SSH_REPLY + read -n 1 -r SSH_REPLY else - if read -p "> " -n 1 -r SSH_REPLY /dev/null; then + if read -n 1 -r SSH_REPLY /dev/null; then : else - echo "(No terminal available - skipping SSH setup)" + echo "(No terminal available - skipping temperature monitoring)" SSH_REPLY="n" fi fi @@ -3744,8 +3847,6 @@ else echo "" if [[ $SSH_REPLY =~ ^[Yy]$ ]]; then - echo "" - echo "" echo "Configuring temperature monitoring..." if [ -n "$SSH_PUBLIC_KEY" ]; then @@ -3776,19 +3877,15 @@ else fi echo "" - echo "Temperature monitoring enabled successfully." - echo "Temperature data will appear in the dashboard within 10 seconds." - echo "" - echo "To disable later, re-run this setup script or manually remove the key:" - echo " grep -v 'pulse' /root/.ssh/authorized_keys > /tmp/ak && mv /tmp/ak /root/.ssh/authorized_keys" + echo "✓ Temperature monitoring enabled" + echo " Temperature data will appear in the dashboard within 10 seconds" TEMPERATURE_ENABLED=true else echo "" - echo "Warning: SSH key not available from Pulse server." - echo "Temperature monitoring cannot be configured automatically." + echo "⚠️ SSH key not available from Pulse server" + echo " Temperature monitoring cannot be configured automatically" fi else - echo "" echo "Temperature monitoring skipped." fi fi @@ -3819,6 +3916,10 @@ if [ "$TEMPERATURE_ENABLED" = true ] && command -v pvecm >/dev/null 2>&1 && comm done <<< "$CLUSTER_NODES" if [ ${#OTHER_NODES_LIST[@]} -gt 0 ]; then + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Cluster Node Configuration" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" echo "Detected additional Proxmox nodes in cluster:" for NODE in "${OTHER_NODES_LIST[@]}"; do @@ -3935,131 +4036,6 @@ EOF fi fi fi -fi # End of ENABLE_TEMP_MONITORING check - -# Check if Pulse is running in a container and offer to install pulse-sensor-proxy -if command -v pct >/dev/null 2>&1 && [ "$TEMPERATURE_ENABLED" = true ]; then - # Extract Pulse IP from URL - PULSE_IP=$(echo "%s" | sed -E 's|^https?://([^:/]+).*|\1|') - - # Find container with this IP - PULSE_CTID="" - if [[ "$PULSE_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - # Check all containers for matching IP - for CTID in $(pct list | awk 'NR>1 {print $1}'); do - # Verify container is running before attempting connection - if ! pct status "$CTID" 2>/dev/null | grep -q "running"; then - continue - fi - - # Get all container IPs (handles both IPv4 and IPv6) - CT_IPS=$(pct exec "$CTID" -- hostname -I 2>/dev/null || printf '') - - # Check if any of the container's IPs match the Pulse IP - for CT_IP in $CT_IPS; do - if [ "$CT_IP" = "$PULSE_IP" ]; then - # Validate with pct config to ensure it's the right container - if pct config "$CTID" >/dev/null 2>&1; then - PULSE_CTID="$CTID" - break 2 # Break out of both loops - fi - fi - done - done - fi - - if [ -n "$PULSE_CTID" ]; then - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "🔒 Enhanced Security" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Detected: Pulse running in container $PULSE_CTID" - echo "" - echo "Recommended: Install secure proxy for temperature monitoring" - echo "This keeps credentials isolated outside the container." - echo "" - echo "Enable secure proxy? [Y/n]" - echo -n "> " - - INSTALL_PROXY="y" - if [ -t 0 ]; then - read -n 1 -r INSTALL_PROXY - else - if read -n 1 -r INSTALL_PROXY /dev/null; then - : - else - echo "(No terminal available - skipping proxy installation)" - INSTALL_PROXY="n" - fi - fi - echo "" - echo "" - - if [[ $INSTALL_PROXY =~ ^[Yy]$|^$ ]]; then - # Download installer script - PROXY_INSTALLER="/tmp/install-sensor-proxy-$$.sh" - if curl --fail --show-error --silent --location \ - "https://github.com/rcourtman/Pulse/releases/latest/download/install-sensor-proxy.sh" \ - -o "$PROXY_INSTALLER" 2>&1; then - chmod +x "$PROXY_INSTALLER" - - # Run installer (suppress verbose output) - if "$PROXY_INSTALLER" --ctid "$PULSE_CTID" --quiet 2>&1 | grep -E "✓|⚠️|ERROR" || "$PROXY_INSTALLER" --ctid "$PULSE_CTID" 2>&1 | grep -E "✓|⚠️|ERROR"; then - # Verify proxy health before removing legacy keys - PROXY_HEALTHY=false - if command -v systemctl >/dev/null 2>&1; then - if systemctl is-active --quiet pulse-sensor-proxy 2>/dev/null; then - PROXY_HEALTHY=true - fi - elif [ -x /usr/local/bin/pulse-sensor-proxy ]; then - # Binary exists and is executable - PROXY_HEALTHY=true - fi - - if [ "$PROXY_HEALTHY" = true ]; then - # Clean up old container-based SSH keys from nodes (silent) - CLEANUP_NODES="" - if [ "$TEMPERATURE_ENABLED" = true ]; then - CLEANUP_NODES="$(hostname)" - fi - if [ -n "${OTHER_NODES_LIST+x}" ] && [ ${#OTHER_NODES_LIST[@]} -gt 0 ]; then - CLEANUP_NODES="$CLEANUP_NODES ${OTHER_NODES_LIST[*]}" - fi - - for NODE in $CLEANUP_NODES; do - if [ -n "$NODE" ] && [ -n "$SSH_PUBLIC_KEY" ]; then - ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ConnectTimeout=5 -o LogLevel=ERROR \ - root@"$NODE" \ - "sed -i '/$SSH_PUBLIC_KEY/d' /root/.ssh/authorized_keys 2>/dev/null || true" \ - >/dev/null 2>&1 - fi - done - - echo "" - echo "✓ Secure proxy architecture enabled" - echo " SSH keys are managed on the host for enhanced security" - else - echo "" - echo "⚠️ Proxy installed but not yet active - keeping existing SSH keys" - echo " Legacy keys will be removed automatically on next setup run" - fi - else - echo "" - echo "⚠️ Installation incomplete - using standard configuration" - fi - - rm -f "$PROXY_INSTALLER" - else - echo "" - echo "⚠️ Network issue - using standard configuration" - echo " (Proxy can be installed later for enhanced security)" - fi - else - echo "Using standard configuration" - fi - fi -fi echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" @@ -4086,7 +4062,7 @@ if [ "$AUTO_REG_SUCCESS" != true ]; then fi `, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP, tokenName, tokenName, tokenName, tokenName, tokenName, tokenName, - authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms, pulseURL, sshPublicKey, pulseURL, authToken, pulseURL, tokenName, serverHost) + authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms, sshPublicKey, pulseURL, pulseURL, pulseURL, pulseURL, authToken, tokenName, serverHost) } else { // PBS script = fmt.Sprintf(`#!/bin/bash diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go index 336da97..e4dc151 100644 --- a/internal/api/diagnostics.go +++ b/internal/api/diagnostics.go @@ -7,16 +7,25 @@ import ( "fmt" "net/http" "os" + "os/user" + "path/filepath" "runtime" "sort" + "strconv" "strings" + "syscall" "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" "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" "github.com/rs/zerolog/log" + "golang.org/x/crypto/ssh" ) // DiagnosticsInfo contains comprehensive diagnostic information @@ -28,6 +37,9 @@ type DiagnosticsInfo struct { PBS []PBSDiagnostic `json:"pbs"` System SystemDiagnostic `json:"system"` TemperatureProxy *TemperatureProxyDiagnostic `json:"temperatureProxy,omitempty"` + APITokens *APITokenDiagnostic `json:"apiTokens,omitempty"` + DockerAgents *DockerAgentDiagnostic `json:"dockerAgents,omitempty"` + Alerts *AlertsDiagnostic `json:"alerts,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"` @@ -162,9 +174,86 @@ 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"` Notes []string `json:"notes,omitempty"` } +// APITokenDiagnostic reports on the state of the multi-token authentication system. +type APITokenDiagnostic struct { + Enabled bool `json:"enabled"` + TokenCount int `json:"tokenCount"` + HasEnvTokens bool `json:"hasEnvTokens"` + HasLegacyToken bool `json:"hasLegacyToken"` + RecommendTokenSetup bool `json:"recommendTokenSetup"` + RecommendTokenRotation bool `json:"recommendTokenRotation"` + LegacyDockerHostCount int `json:"legacyDockerHostCount,omitempty"` + UnusedTokenCount int `json:"unusedTokenCount,omitempty"` + Notes []string `json:"notes,omitempty"` + Tokens []APITokenSummary `json:"tokens,omitempty"` + Usage []APITokenUsage `json:"usage,omitempty"` +} + +// APITokenSummary provides sanitized token metadata for diagnostics display. +type APITokenSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Hint string `json:"hint,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUsedAt string `json:"lastUsedAt,omitempty"` + Source string `json:"source,omitempty"` +} + +// APITokenUsage summarises how tokens are consumed by connected agents. +type APITokenUsage struct { + TokenID string `json:"tokenId"` + HostCount int `json:"hostCount"` + Hosts []string `json:"hosts,omitempty"` +} + +// DockerAgentDiagnostic summarizes adoption of the Docker agent command system. +type DockerAgentDiagnostic struct { + HostsTotal int `json:"hostsTotal"` + HostsOnline int `json:"hostsOnline"` + HostsReportingVersion int `json:"hostsReportingVersion"` + HostsWithTokenBinding int `json:"hostsWithTokenBinding"` + HostsWithoutTokenBinding int `json:"hostsWithoutTokenBinding"` + HostsWithoutVersion int `json:"hostsWithoutVersion,omitempty"` + HostsOutdatedVersion int `json:"hostsOutdatedVersion,omitempty"` + HostsWithStaleCommand int `json:"hostsWithStaleCommand,omitempty"` + HostsPendingUninstall int `json:"hostsPendingUninstall,omitempty"` + HostsNeedingAttention int `json:"hostsNeedingAttention"` + RecommendedAgentVersion string `json:"recommendedAgentVersion,omitempty"` + Attention []DockerAgentAttention `json:"attention,omitempty"` + Notes []string `json:"notes,omitempty"` +} + +// DockerAgentAttention captures an individual agent that requires user action. +type DockerAgentAttention struct { + HostID string `json:"hostId"` + Name string `json:"name"` + Status string `json:"status"` + AgentVersion string `json:"agentVersion,omitempty"` + TokenHint string `json:"tokenHint,omitempty"` + LastSeen string `json:"lastSeen,omitempty"` + Issues []string `json:"issues"` +} + +// AlertsDiagnostic summarises alert configuration migration state. +type AlertsDiagnostic struct { + LegacyThresholdsDetected bool `json:"legacyThresholdsDetected"` + LegacyThresholdSources []string `json:"legacyThresholdSources,omitempty"` + LegacyScheduleSettings []string `json:"legacyScheduleSettings,omitempty"` + MissingCooldown bool `json:"missingCooldown"` + MissingGroupingWindow bool `json:"missingGroupingWindow"` + Notes []string `json:"notes,omitempty"` +} + // handleDiagnostics returns comprehensive diagnostic information func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) { ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second) @@ -199,40 +288,8 @@ func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) { } legacySSH, recommendProxy := r.detectLegacySSH() - proxyDiag := &TemperatureProxyDiagnostic{ - LegacySSHDetected: legacySSH, - RecommendProxyUpgrade: recommendProxy, - } - - socketPaths := []string{ - "/mnt/pulse-proxy/pulse-sensor-proxy.sock", - "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock", - } - - for _, path := range socketPaths { - if info, err := os.Stat(path); err == nil { - if info.Mode()&os.ModeSocket != 0 { - proxyDiag.SocketFound = true - proxyDiag.SocketPath = path - proxyDiag.SocketPermissions = fmt.Sprintf("%#o", info.Mode().Perm()) - break - } - } else if !errors.Is(err, os.ErrNotExist) { - proxyDiag.Notes = append(proxyDiag.Notes, fmt.Sprintf("Unable to inspect proxy socket at %s: %v", path, err)) - } - } - - if !proxyDiag.SocketFound { - proxyDiag.Notes = append(proxyDiag.Notes, "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).") - } else if proxyDiag.SocketPath == "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" { - proxyDiag.Notes = append(proxyDiag.Notes, "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).") - } - - if proxyDiag.LegacySSHDetected && proxyDiag.RecommendProxyUpgrade { - proxyDiag.Notes = append(proxyDiag.Notes, "Legacy SSH configuration detected. Remove each node from Pulse and re-add it using the installer script copied from Settings → Nodes (or rerun the host installer script) to migrate to the secure proxy.") - } - - diag.TemperatureProxy = proxyDiag + diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config, legacySSH, recommendProxy) + diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor) // Test each configured node for _, node := range r.config.PVEInstances { @@ -354,6 +411,9 @@ func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) { diag.PBS = append(diag.PBS, pbsDiag) } + diag.DockerAgents = buildDockerAgentDiagnostic(r.monitor, diag.Version) + diag.Alerts = buildAlertsDiagnostic(r.monitor) + // Include cached monitor snapshots for memory diagnostics if available if r.monitor != nil { snapshots := r.monitor.GetDiagnosticSnapshots() @@ -423,6 +483,558 @@ func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) { } } +func buildTemperatureProxyDiagnostic(cfg *config.Config, legacyDetected, recommendProxy bool) *TemperatureProxyDiagnostic { + diag := &TemperatureProxyDiagnostic{ + LegacySSHDetected: legacyDetected, + RecommendProxyUpgrade: recommendProxy, + } + + 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).") + } else if diag.SocketPath == "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" { + 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)) + } + } + } + } 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)) + } + } + + if diag.LegacySSHDetected && diag.RecommendProxyUpgrade { + appendNote("Legacy SSH temperature collection detected. Remove each node from Pulse and re-add it using the installer script copied from Settings → Nodes (or rerun the host installer script) to migrate to the secure proxy.") + } + + return diag +} + +func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *APITokenDiagnostic { + if cfg == nil { + return nil + } + + diag := &APITokenDiagnostic{ + Enabled: cfg.APITokenEnabled && !cfg.DisableAuth, + TokenCount: len(cfg.APITokens), + } + + appendNote := func(note string) { + if note == "" || contains(diag.Notes, note) { + return + } + diag.Notes = append(diag.Notes, note) + } + + envTokens := false + if cfg.EnvOverrides != nil && (cfg.EnvOverrides["API_TOKEN"] || cfg.EnvOverrides["API_TOKENS"]) { + envTokens = true + } + + legacyToken := false + for _, record := range cfg.APITokens { + if strings.EqualFold(record.Name, "Environment token") { + envTokens = true + } + if strings.EqualFold(record.Name, "Legacy token") { + legacyToken = true + } + } + + diag.HasEnvTokens = envTokens + diag.HasLegacyToken = legacyToken + diag.RecommendTokenSetup = len(cfg.APITokens) == 0 + diag.RecommendTokenRotation = envTokens || legacyToken + + if cfg.DisableAuth { + appendNote("Authentication is disabled (DISABLE_AUTH=1). Re-enable it to use per-agent API tokens.") + } else 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 { + appendNote("No API tokens are configured. Open Settings → Security to generate dedicated tokens for each automation or agent.") + } + + tokens := make([]APITokenSummary, 0, len(cfg.APITokens)) + unusedCount := 0 + for _, record := range cfg.APITokens { + summary := APITokenSummary{ + ID: record.ID, + Name: record.Name, + } + + if !record.CreatedAt.IsZero() { + summary.CreatedAt = record.CreatedAt.UTC().Format(time.RFC3339) + } + + if record.LastUsedAt != nil && !record.LastUsedAt.IsZero() { + summary.LastUsedAt = record.LastUsedAt.UTC().Format(time.RFC3339) + } else { + unusedCount++ + } + + switch { + case record.Prefix != "" && record.Suffix != "": + summary.Hint = fmt.Sprintf("%s…%s", record.Prefix, record.Suffix) + case record.Prefix != "": + summary.Hint = record.Prefix + "…" + case record.Suffix != "": + summary.Hint = "…" + record.Suffix + } + + switch { + case strings.EqualFold(record.Name, "Environment token"): + summary.Source = "environment" + case strings.EqualFold(record.Name, "Legacy token"): + summary.Source = "legacy" + default: + summary.Source = "user" + } + + tokens = append(tokens, summary) + } + + diag.Tokens = tokens + diag.UnusedTokenCount = unusedCount + + if len(cfg.APITokens) > 0 { + if unusedCount == len(cfg.APITokens) { + appendNote("Configured API tokens have not been used yet. Update your agents or automations to switch to the new tokens.") + } else if unusedCount > 0 { + appendNote(fmt.Sprintf("%d API token(s) have never been used. Remove unused tokens or update the corresponding agents.", unusedCount)) + } + } + + tokenUsage := make(map[string][]string) + legacyHosts := 0 + if monitor != nil { + for _, host := range monitor.GetDockerHosts() { + name := preferredDockerHostName(host) + if strings.TrimSpace(host.TokenID) == "" { + legacyHosts++ + continue + } + tokenID := strings.TrimSpace(host.TokenID) + tokenUsage[tokenID] = append(tokenUsage[tokenID], name) + } + } + + diag.LegacyDockerHostCount = legacyHosts + if legacyHosts > 0 { + appendNote(fmt.Sprintf("%d Docker host(s) still rely on the shared API token. Generate dedicated tokens and rerun the installer from Settings → Docker Agents.", legacyHosts)) + } + + if len(tokenUsage) > 0 { + keys := make([]string, 0, len(tokenUsage)) + for tokenID := range tokenUsage { + keys = append(keys, tokenID) + } + sort.Strings(keys) + + diag.Usage = make([]APITokenUsage, 0, len(keys)) + for _, tokenID := range keys { + hosts := tokenUsage[tokenID] + sort.Strings(hosts) + diag.Usage = append(diag.Usage, APITokenUsage{ + TokenID: tokenID, + HostCount: len(hosts), + Hosts: hosts, + }) + } + } + + if envTokens { + appendNote("Environment-based API token detected. Migrate to tokens created in the UI for per-token tracking and safer rotation.") + } + if legacyToken { + appendNote("Legacy token detected. Generate new API tokens and update integrations to benefit from per-token management.") + } + + return diag +} + +func buildDockerAgentDiagnostic(m *monitoring.Monitor, serverVersion string) *DockerAgentDiagnostic { + if m == nil { + return nil + } + + hosts := m.GetDockerHosts() + diag := &DockerAgentDiagnostic{ + HostsTotal: len(hosts), + RecommendedAgentVersion: normalizeVersionLabel(serverVersion), + } + + appendNote := func(note string) { + if note == "" || contains(diag.Notes, note) { + return + } + diag.Notes = append(diag.Notes, note) + } + + if len(hosts) == 0 { + appendNote("No Docker agents have reported in yet. Use Settings → Docker Agents to install the container-side agent and unlock remote commands.") + return diag + } + + var ( + serverVer *updates.Version + recommendedLabel = diag.RecommendedAgentVersion + ) + if serverVersion != "" { + if parsed, err := updates.ParseVersion(serverVersion); err == nil { + serverVer = parsed + recommendedLabel = normalizeVersionLabel(parsed.String()) + diag.RecommendedAgentVersion = recommendedLabel + } + } + + now := time.Now().UTC() + legacyTokenHosts := 0 + for _, host := range hosts { + status := strings.ToLower(strings.TrimSpace(host.Status)) + if status == "online" { + diag.HostsOnline++ + } + versionStr := strings.TrimSpace(host.AgentVersion) + if versionStr != "" { + diag.HostsReportingVersion++ + } else { + diag.HostsWithoutVersion++ + } + + if strings.TrimSpace(host.TokenID) != "" { + diag.HostsWithTokenBinding++ + } else { + legacyTokenHosts++ + } + + issues := make([]string, 0, 4) + + if status != "online" && status != "" { + issues = append(issues, fmt.Sprintf("Host reports status %q.", status)) + } + + if versionStr == "" { + issues = append(issues, "Agent has not reported a version (pre v4.24). Reinstall using Settings → Docker Agents.") + } else if serverVer != nil { + if agentVer, err := updates.ParseVersion(versionStr); err == nil { + if agentVer.Compare(serverVer) < 0 { + diag.HostsOutdatedVersion++ + issues = append(issues, fmt.Sprintf("Agent version %s lags behind the recommended %s. Re-run the installer to update.", normalizeVersionLabel(versionStr), recommendedLabel)) + } + } else { + issues = append(issues, fmt.Sprintf("Unrecognized agent version string %q. Reinstall to ensure command support.", versionStr)) + } + } + + if strings.TrimSpace(host.TokenID) == "" { + issues = append(issues, "Host is still using the shared API token. Generate a dedicated token in Settings → Security and rerun the installer.") + } + + if !host.LastSeen.IsZero() && now.Sub(host.LastSeen.UTC()) > 10*time.Minute { + issues = append(issues, fmt.Sprintf("No heartbeat since %s. Verify the agent container is running.", host.LastSeen.UTC().Format(time.RFC3339))) + } + + if host.Command != nil { + cmdStatus := strings.ToLower(strings.TrimSpace(host.Command.Status)) + switch cmdStatus { + case monitoring.DockerCommandStatusQueued, monitoring.DockerCommandStatusDispatched, monitoring.DockerCommandStatusAcknowledged: + message := fmt.Sprintf("Command %s is still in progress.", cmdStatus) + if !host.Command.UpdatedAt.IsZero() && now.Sub(host.Command.UpdatedAt.UTC()) > 15*time.Minute { + diag.HostsWithStaleCommand++ + message = fmt.Sprintf("Command %s has been pending since %s; consider allowing re-enrolment.", cmdStatus, host.Command.UpdatedAt.UTC().Format(time.RFC3339)) + } + issues = append(issues, message) + } + } + + if host.PendingUninstall { + diag.HostsPendingUninstall++ + issues = append(issues, "Host is pending uninstall; confirm the agent container stopped or clear the flag.") + } + + if len(issues) == 0 { + continue + } + + diag.Attention = append(diag.Attention, DockerAgentAttention{ + HostID: host.ID, + Name: preferredDockerHostName(host), + Status: host.Status, + AgentVersion: versionStr, + TokenHint: host.TokenHint, + LastSeen: formatTimeMaybe(host.LastSeen), + Issues: issues, + }) + } + + diag.HostsWithoutTokenBinding = legacyTokenHosts + diag.HostsNeedingAttention = len(diag.Attention) + + if legacyTokenHosts > 0 { + appendNote(fmt.Sprintf("%d Docker host(s) still rely on the shared API token. Migrate each host to a dedicated token via Settings → Security and rerun the installer.", legacyTokenHosts)) + } + if diag.HostsOutdatedVersion > 0 { + appendNote(fmt.Sprintf("%d Docker host(s) run an out-of-date agent. Re-run the installer from Settings → Docker Agents to upgrade them.", diag.HostsOutdatedVersion)) + } + if diag.HostsWithoutVersion > 0 { + appendNote(fmt.Sprintf("%d Docker host(s) have not reported an agent version yet. Reinstall the agent to enable the new command system.", diag.HostsWithoutVersion)) + } + if diag.HostsWithStaleCommand > 0 { + appendNote(fmt.Sprintf("%d Docker host command(s) appear stuck. Use the 'Allow re-enroll' action in Settings → Docker Agents to reset them.", diag.HostsWithStaleCommand)) + } + if diag.HostsPendingUninstall > 0 { + appendNote(fmt.Sprintf("%d Docker host(s) are pending uninstall. Confirm the uninstall or clear the flag from Settings → Docker Agents.", diag.HostsPendingUninstall)) + } + if diag.HostsNeedingAttention == 0 { + appendNote("All Docker agents are reporting with dedicated tokens and the expected version.") + } + + return diag +} + +func buildAlertsDiagnostic(m *monitoring.Monitor) *AlertsDiagnostic { + if m == nil { + return nil + } + + manager := m.GetAlertManager() + if manager == nil { + return nil + } + + config := manager.GetConfig() + diag := &AlertsDiagnostic{} + + appendNote := func(note string) { + if note == "" || contains(diag.Notes, note) { + return + } + diag.Notes = append(diag.Notes, note) + } + + legacySources := make([]string, 0, 4) + if hasLegacyThresholds(config.GuestDefaults) { + diag.LegacyThresholdsDetected = true + legacySources = append(legacySources, "guest-defaults") + } + if hasLegacyThresholds(config.NodeDefaults) { + diag.LegacyThresholdsDetected = true + legacySources = append(legacySources, "node-defaults") + } + + overrideIndex := 0 + for _, override := range config.Overrides { + overrideIndex++ + if hasLegacyThresholds(override) { + diag.LegacyThresholdsDetected = true + legacySources = append(legacySources, fmt.Sprintf("override-%d", overrideIndex)) + } + } + + for idx, rule := range config.CustomRules { + if hasLegacyThresholds(rule.Thresholds) { + diag.LegacyThresholdsDetected = true + legacySources = append(legacySources, fmt.Sprintf("custom-%d", idx+1)) + } + } + + if len(legacySources) > 0 { + sort.Strings(legacySources) + diag.LegacyThresholdSources = legacySources + appendNote("Some alert rules still rely on legacy single-value thresholds. Edit and save them to enable hysteresis-based alerts.") + } + + legacySchedule := make([]string, 0, 2) + if config.TimeThreshold > 0 { + legacySchedule = append(legacySchedule, "timeThreshold") + appendNote("Global alert delay still uses the legacy timeThreshold setting. Save the alerts configuration to migrate to per-metric delays.") + } + if config.Schedule.GroupingWindow > 0 && config.Schedule.Grouping.Window == 0 { + legacySchedule = append(legacySchedule, "groupingWindow") + appendNote("Alert grouping uses the deprecated groupingWindow value. Update the schedule to use the new grouping options.") + } + if len(legacySchedule) > 0 { + sort.Strings(legacySchedule) + diag.LegacyScheduleSettings = legacySchedule + } + + if config.Schedule.Cooldown <= 0 { + diag.MissingCooldown = true + appendNote("Alert cooldown is not configured. Set a cooldown under Settings → Alerts → Schedule to prevent alert storms.") + } + if config.Schedule.Grouping.Window <= 0 { + diag.MissingGroupingWindow = true + appendNote("Alert grouping window is disabled. Configure a grouping window to bundle related alerts.") + } + + return diag +} + +func fingerprintPublicKey(pub string) (string, error) { + pub = strings.TrimSpace(pub) + if pub == "" { + return "", fmt.Errorf("empty public key") + } + key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pub)) + if err != nil { + return "", err + } + return ssh.FingerprintSHA256(key), nil +} + +func resolveUserName(uid uint32) string { + uidStr := strconv.FormatUint(uint64(uid), 10) + if usr, err := user.LookupId(uidStr); err == nil && usr.Username != "" { + return usr.Username + } + return "uid:" + uidStr +} + +func resolveGroupName(gid uint32) string { + gidStr := strconv.FormatUint(uint64(gid), 10) + if grp, err := user.LookupGroupId(gidStr); err == nil && grp != nil && grp.Name != "" { + return grp.Name + } + return "gid:" + gidStr +} + +func countLegacySSHKeys(dir string) (int, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return 0, nil + } + return 0, err + } + count := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, "id_") { + count++ + } + } + return count, nil +} + +func hasLegacyThresholds(th alerts.ThresholdConfig) bool { + return th.CPULegacy != nil || + th.MemoryLegacy != nil || + th.DiskLegacy != nil || + th.DiskReadLegacy != nil || + th.DiskWriteLegacy != nil || + th.NetworkInLegacy != nil || + th.NetworkOutLegacy != nil +} + +func preferredDockerHostName(host models.DockerHost) string { + if name := strings.TrimSpace(host.DisplayName); name != "" { + return name + } + if name := strings.TrimSpace(host.Hostname); name != "" { + return name + } + if name := strings.TrimSpace(host.AgentID); name != "" { + return name + } + return host.ID +} + +func formatTimeMaybe(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +func normalizeVersionLabel(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + if strings.HasPrefix(value, "v") { + return value + } + first := value[0] + if first < '0' || first > '9' { + return value + } + return "v" + value +} + // checkVMDiskMonitoring performs diagnostic checks for VM disk monitoring func (r *Router) checkVMDiskMonitoring(ctx context.Context, client *proxmox.Client, nodeName string) *VMDiskCheckResult { result := &VMDiskCheckResult{ diff --git a/internal/api/router.go b/internal/api/router.go index 4416c37..74cd183 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "net/http" "os" "os/exec" @@ -21,6 +22,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/dockeragent" "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/internal/utils" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" @@ -45,6 +47,7 @@ type Router struct { oidcMu sync.Mutex oidcService *OIDCService wrapped http.Handler + projectRoot string // Cached system settings to avoid loading from disk on every request settingsMu sync.RWMutex cachedAllowEmbedding bool @@ -57,6 +60,11 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket InitSessionStore(cfg.DataPath) InitCSRFStore(cfg.DataPath) + projectRoot, err := os.Getwd() + if err != nil { + projectRoot = "." + } + r := &Router{ mux: http.NewServeMux(), config: cfg, @@ -66,6 +74,7 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket updateManager: updates.NewManager(cfg), exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute persistence: config.NewConfigPersistence(cfg.DataPath), + projectRoot: projectRoot, } r.setupRoutes() @@ -120,6 +129,10 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts) r.mux.HandleFunc("/api/charts", r.handleCharts) r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics)) + r.mux.HandleFunc("/api/diagnostics/temperature-proxy/register-nodes", RequireAdmin(r.config, r.handleDiagnosticsRegisterProxyNodes)) + r.mux.HandleFunc("/api/diagnostics/docker/prepare-token", RequireAdmin(r.config, r.handleDiagnosticsDockerPrepareToken)) + r.mux.HandleFunc("/api/install/pulse-sensor-proxy", r.handleDownloadPulseSensorProxy) + r.mux.HandleFunc("/api/install/install-sensor-proxy.sh", r.handleDownloadInstallerScript) r.mux.HandleFunc("/api/config", r.handleConfig) r.mux.HandleFunc("/api/backups", r.handleBackups) r.mux.HandleFunc("/api/backups/", r.handleBackups) @@ -1071,10 +1084,12 @@ 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 - "/download/pulse-docker-agent", // Agent binary download should not require auth - "/api/agent/version", // Agent update checks need to work before auth - "/api/server/info", // Server info for installer script + "/install-docker-agent.sh", // Docker agent bootstrap script must be public + "/download/pulse-docker-agent", // Agent binary download should not require auth + "/api/agent/version", // Agent update checks need to work before auth + "/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 } // Also allow static assets without auth (JS, CSS, etc) @@ -1128,6 +1143,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { if normalizedPath == "/api/security/quick-setup" && req.Method == http.MethodPost { isPublic = true } + // Dev mode bypass for admin endpoints (disabled by default) + if os.Getenv("ALLOW_ADMIN_BYPASS") == "1" { + log.Info(). + Str("path", req.URL.Path). + Msg("=== ADMIN BYPASS ENABLED - SKIPPING GLOBAL AUTH ===") + needsAuth = false + } + // Check auth for protected routes (only if auth is needed) if needsAuth && !isPublic && !CheckAuth(r.config, w, req) { // Never send WWW-Authenticate - use custom login page @@ -2850,6 +2873,227 @@ func (r *Router) handleDownloadAgent(w http.ResponseWriter, req *http.Request) { http.Error(w, "Agent binary not found", http.StatusNotFound) } +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 { + 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) + return + } + + var payload struct { + HostID string `json:"hostId"` + TokenName string `json:"tokenName"` + } + + if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", nil) + return + } + + hostID := strings.TrimSpace(payload.HostID) + if hostID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "hostId is required", nil) + return + } + + host, ok := r.monitor.GetDockerHost(hostID) + if !ok { + writeErrorResponse(w, http.StatusNotFound, "host_not_found", "Docker host not found", nil) + return + } + + name := strings.TrimSpace(payload.TokenName) + if name == "" { + displayName := preferredDockerHostName(host) + name = fmt.Sprintf("Docker host: %s", displayName) + } + + rawToken, err := auth.GenerateAPIToken() + if err != nil { + log.Error().Err(err).Msg("Failed to generate docker migration token") + writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate API token", nil) + return + } + + record, err := config.NewAPITokenRecord(rawToken, name) + if err != nil { + log.Error().Err(err).Msg("Failed to construct token record for docker migration") + writeErrorResponse(w, http.StatusInternalServerError, "token_generation_failed", "Failed to generate API token", nil) + return + } + + r.config.APITokens = append(r.config.APITokens, *record) + r.config.SortAPITokens() + r.config.APITokenEnabled = true + + if r.persistence != nil { + if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil { + r.config.RemoveAPIToken(record.ID) + log.Error().Err(err).Msg("Failed to persist API tokens after docker migration generation") + writeErrorResponse(w, http.StatusInternalServerError, "token_persist_failed", "Failed to persist API token", nil) + return + } + } + + baseURL := strings.TrimRight(r.resolvePublicURL(req), "/") + installCommand := fmt.Sprintf("curl -fsSL %s/install-docker-agent.sh | bash -s -- --url %s --token %s", baseURL, baseURL, rawToken) + systemdSnippet := fmt.Sprintf("[Service]\nType=simple\nEnvironment=\"PULSE_URL=%s\"\nEnvironment=\"PULSE_TOKEN=%s\"\nExecStart=/usr/local/bin/pulse-docker-agent --url %s --interval 30s\nRestart=always\nRestartSec=5s\nUser=root", baseURL, rawToken, baseURL) + + response := map[string]any{ + "success": true, + "token": rawToken, + "record": toAPITokenDTO(*record), + "host": map[string]any{ + "id": host.ID, + "name": preferredDockerHostName(host), + }, + "installCommand": installCommand, + "systemdServiceSnippet": systemdSnippet, + "pulseURL": baseURL, + } + + if err := utils.WriteJSONResponse(w, response); err != nil { + log.Error().Err(err).Msg("Failed to serialize docker token migration response") + } +} + +func (r *Router) handleDownloadPulseSensorProxy(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) + return + } + + arch := strings.TrimSpace(req.URL.Query().Get("arch")) + if arch == "" { + arch = runtime.GOARCH + } + + if runtime.GOOS != "linux" && arch != "linux-amd64" { + writeErrorResponse(w, http.StatusBadRequest, "unsupported_arch", "Only linux-amd64 builds are supported in this environment", nil) + return + } + + tmpFile, err := os.CreateTemp("", "pulse-sensor-proxy-*.bin") + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "tempfile_error", "Failed to create temporary file", nil) + return + } + tmpFileName := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpFileName) + + cmd := exec.Command("go", "build", "-o", tmpFileName, "./cmd/pulse-sensor-proxy") + cmd.Dir = r.projectRoot + cmd.Env = append(os.Environ(), + "GOOS=linux", + "GOARCH=amd64", + "CGO_ENABLED=0", + ) + + buildOutput, err := cmd.CombinedOutput() + if err != nil { + log.Error().Err(err).Bytes("output", buildOutput).Msg("Failed to build pulse-sensor-proxy binary") + writeErrorResponse(w, http.StatusInternalServerError, "build_failed", "Failed to build proxy binary on the server", nil) + return + } + + builtFile, err := os.Open(tmpFileName) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "open_error", "Failed to open built proxy binary", nil) + return + } + defer builtFile.Close() + + stat, err := builtFile.Stat() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, "stat_error", "Failed to stat built binary", nil) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"pulse-sensor-proxy-linux-amd64\"")) + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) + + if _, err := io.Copy(w, builtFile); err != nil { + log.Error().Err(err).Msg("Failed to stream proxy binary to client") + } +} + +func (r *Router) handleDownloadInstallerScript(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil) + return + } + + scriptPath := filepath.Join(r.projectRoot, "scripts", "install-sensor-proxy.sh") + content, err := os.ReadFile(scriptPath) + if err != nil { + 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) resolvePublicURL(req *http.Request) string { + if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" { + return strings.TrimRight(publicURL, "/") + } + + scheme := "http" + if req != nil { + if req.TLS != nil { + scheme = "https" + } else if proto := req.Header.Get("X-Forwarded-Proto"); strings.EqualFold(proto, "https") { + scheme = "https" + } + } + + host := "" + if req != nil { + host = strings.TrimSpace(req.Host) + } + if host == "" { + if r.config.FrontendPort > 0 { + host = fmt.Sprintf("localhost:%d", r.config.FrontendPort) + } else { + host = "localhost:7655" + } + } + + return fmt.Sprintf("%s://%s", scheme, host) +} + func normalizeDockerAgentArch(arch string) string { if arch == "" { return "" diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 9a0d25b..165864f 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -548,6 +548,14 @@ func (m *Monitor) GetDockerHost(hostID string) (models.DockerHost, bool) { return models.DockerHost{}, false } +// GetDockerHosts returns a point-in-time snapshot of all Docker hosts Pulse knows about. +func (m *Monitor) GetDockerHosts() []models.DockerHost { + if m == nil || m.state == nil { + return nil + } + return m.state.GetDockerHosts() +} + // QueueDockerHostStop queues a stop command for the specified docker host. func (m *Monitor) QueueDockerHostStop(hostID string) (models.DockerHostCommandStatus, error) { return m.queueDockerStopCommand(hostID) @@ -2552,7 +2560,7 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie // Collect temperature data via SSH (non-blocking, best effort) // Only attempt for online nodes if node.Status == "online" && m.tempCollector != nil { - tempCtx, tempCancel := context.WithTimeout(ctx, 5*time.Second) + tempCtx, tempCancel := context.WithTimeout(ctx, 30*time.Second) // Increased to accommodate SSH operations via proxy // Determine SSH hostname to use (most robust approach): // 1. For cluster nodes: Try to find the node's specific IP or host from ClusterEndpoints diff --git a/internal/tempproxy/client.go b/internal/tempproxy/client.go index 678f539..d4a5d75 100644 --- a/internal/tempproxy/client.go +++ b/internal/tempproxy/client.go @@ -13,7 +13,7 @@ import ( const ( defaultSocketPath = "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" containerSocketPath = "/mnt/pulse-proxy/pulse-sensor-proxy.sock" - defaultTimeout = 10 * time.Second + defaultTimeout = 30 * time.Second // Increased to accommodate SSH operations ) // Client communicates with pulse-sensor-proxy via unix socket @@ -83,7 +83,7 @@ func (c *Client) call(method string, params map[string]interface{}) (*RPCRespons return nil, fmt.Errorf("failed to encode request: %w", err) } - // Read response + // Read response (server uses newline-delimited framing, no CloseWrite needed) var resp RPCResponse decoder := json.NewDecoder(conn) if err := decoder.Decode(&resp); err != nil { @@ -107,22 +107,6 @@ func (c *Client) GetStatus() (map[string]interface{}, error) { return resp.Data, nil } -// EnsureClusterKeys discovers cluster nodes and pushes SSH keys -func (c *Client) EnsureClusterKeys() (map[string]interface{}, error) { - log.Info().Msg("Requesting proxy to configure cluster SSH keys") - - resp, err := c.call("ensure_cluster_keys", nil) - if err != nil { - return nil, err - } - - if !resp.Success { - return nil, fmt.Errorf("proxy error: %s", resp.Error) - } - - return resp.Data, nil -} - // RegisterNodes returns list of discovered nodes with SSH status func (c *Client) RegisterNodes() ([]map[string]interface{}, error) { resp, err := c.call("register_nodes", nil) diff --git a/scripts/hot-dev.sh b/scripts/hot-dev.sh index 74a8f91..f96f97c 100755 --- a/scripts/hot-dev.sh +++ b/scripts/hot-dev.sh @@ -61,8 +61,8 @@ Mock Mode: ${PULSE_MOCK_MODE:-false} Toggle mock mode: npm run mock:on / npm run mock:off Mock config: npm run mock:edit -Just edit frontend files and see changes instantly! -Backend auto-reloads when mock.env changes! +Frontend: Edit files and see changes instantly! +Backend: Auto-rebuilds when .go files change! Press Ctrl+C to stop ========================================= BANNER @@ -75,7 +75,12 @@ kill_port() { printf "[hot-dev] Cleaning up existing processes...\n" -sudo systemctl stop pulse-hot-dev 2>/dev/null || true +# Don't stop ourselves if we're running under systemd +if [[ -z "${INVOCATION_ID:-}" ]]; then + # Not running under systemd, safe to stop the service + sudo systemctl stop pulse-hot-dev 2>/dev/null || true +fi + sudo systemctl stop pulse-backend 2>/dev/null || true sudo systemctl stop pulse 2>/dev/null || true sudo systemctl stop pulse-frontend 2>/dev/null || true @@ -183,9 +188,62 @@ if ! kill -0 "${BACKEND_PID}" 2>/dev/null; then exit 1 fi +# Start backend file watcher in background +printf "[hot-dev] Starting backend file watcher...\n" +( + cd "${ROOT_DIR}" + while true; do + # Watch for changes to .go files (excluding vendor and node_modules) + inotifywait -r -e modify,create,delete,move \ + --exclude '(vendor/|node_modules/|\.git/)' \ + --format '%w%f' \ + "${ROOT_DIR}/cmd" "${ROOT_DIR}/internal" "${ROOT_DIR}/pkg" 2>/dev/null | \ + while read -r changed_file; do + if [[ "$changed_file" == *.go ]]; then + echo "" + echo "[hot-dev] 🔄 Go file changed: $(basename "$changed_file")" + echo "[hot-dev] Rebuilding backend..." + + # Rebuild the binary + if go build -o pulse ./cmd/pulse 2>&1 | grep -v "^#"; then + echo "[hot-dev] ✓ Build successful, restarting backend..." + + # Find and kill old backend + OLD_PID=$(pgrep -f "^\./pulse$" || true) + if [[ -n "$OLD_PID" ]]; then + kill "$OLD_PID" 2>/dev/null || true + sleep 1 + if kill -0 "$OLD_PID" 2>/dev/null; then + kill -9 "$OLD_PID" 2>/dev/null || true + fi + fi + + # Start new backend with same environment + FRONTEND_PORT=${PULSE_DEV_API_PORT} PORT=${PULSE_DEV_API_PORT} PULSE_DATA_DIR=${PULSE_DATA_DIR} ./pulse & + NEW_PID=$! + sleep 1 + + if kill -0 "$NEW_PID" 2>/dev/null; then + echo "[hot-dev] ✓ Backend restarted (PID: $NEW_PID)" + else + echo "[hot-dev] ✗ Backend failed to start!" + fi + else + echo "[hot-dev] ✗ Build failed!" + fi + echo "[hot-dev] Watching for changes..." + fi + done + done +) & +WATCHER_PID=$! + cleanup() { echo "" echo "Stopping services..." + if [[ -n ${WATCHER_PID:-} ]] && kill -0 "${WATCHER_PID}" 2>/dev/null; then + kill "${WATCHER_PID}" 2>/dev/null || true + fi if [[ -n ${BACKEND_PID:-} ]] && kill -0 "${BACKEND_PID}" 2>/dev/null; then kill "${BACKEND_PID}" 2>/dev/null || true sleep 1 @@ -197,6 +255,7 @@ cleanup() { pkill -f vite 2>/dev/null || true pkill -f "npm run dev" 2>/dev/null || true pkill -9 -x "pulse" 2>/dev/null || true + pkill -f "inotifywait.*pulse" 2>/dev/null || true echo "Hot-dev stopped. To restart normal service, run: sudo systemctl start pulse" echo "(Legacy installs may use: sudo systemctl start pulse-backend)" } diff --git a/scripts/install-sensor-proxy.sh b/scripts/install-sensor-proxy.sh index c34a59d..62be7ec 100755 --- a/scripts/install-sensor-proxy.sh +++ b/scripts/install-sensor-proxy.sh @@ -40,6 +40,8 @@ CTID="" VERSION="latest" LOCAL_BINARY="" QUIET=false +PULSE_SERVER="" +FALLBACK_BASE="${PULSE_SENSOR_PROXY_FALLBACK_URL:-}" while [[ $# -gt 0 ]]; do case $1 in @@ -55,6 +57,10 @@ while [[ $# -gt 0 ]]; do LOCAL_BINARY="$2" shift 2 ;; + --pulse-server) + PULSE_SERVER="$2" + shift 2 + ;; --quiet) QUIET=true shift @@ -66,9 +72,14 @@ while [[ $# -gt 0 ]]; do esac done +# If --pulse-server was provided, use it as the fallback base +if [[ -n "$PULSE_SERVER" ]]; then + FALLBACK_BASE="${PULSE_SERVER}/api/install/pulse-sensor-proxy" +fi + if [[ -z "$CTID" ]]; then print_error "Missing required argument: --ctid " - echo "Usage: $0 --ctid [--version ] [--local-binary ]" + echo "Usage: $0 --ctid [--pulse-server ] [--version ] [--local-binary ]" exit 1 fi @@ -95,6 +106,12 @@ else print_info "Service account pulse-sensor-proxy already exists" fi +# Add pulse-sensor-proxy user to www-data group for Proxmox IPC access (pvecm commands) +if ! groups pulse-sensor-proxy | grep -q '\bwww-data\b'; then + print_info "Adding pulse-sensor-proxy to www-data group for Proxmox IPC access..." + usermod -aG www-data pulse-sensor-proxy +fi + # Install binary - either from local file or download from GitHub if [[ -n "$LOCAL_BINARY" ]]; then # Use local binary for testing @@ -107,31 +124,20 @@ if [[ -n "$LOCAL_BINARY" ]]; then chmod +x "$BINARY_PATH" print_info "Binary installed to $BINARY_PATH" else - # Download from GitHub release - GITHUB_REPO="rcourtman/Pulse" - if [[ "$VERSION" == "latest" ]]; then - RELEASE_URL="https://api.github.com/repos/$GITHUB_REPO/releases/latest" - print_info "Fetching latest release info..." - RELEASE_DATA=$(curl -fsSL "$RELEASE_URL") - VERSION=$(echo "$RELEASE_DATA" | grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4) - if [[ -z "$VERSION" ]]; then - print_error "Failed to determine latest version" - exit 1 - fi - print_info "Latest version: $VERSION" - fi - # Detect architecture ARCH=$(uname -m) case $ARCH in x86_64) BINARY_NAME="pulse-sensor-proxy-linux-amd64" + ARCH_LABEL="linux-amd64" ;; aarch64|arm64) BINARY_NAME="pulse-sensor-proxy-linux-arm64" + ARCH_LABEL="linux-arm64" ;; armv7l|armhf) BINARY_NAME="pulse-sensor-proxy-linux-armv7" + ARCH_LABEL="linux-armv7" ;; *) print_error "Unsupported architecture: $ARCH" @@ -139,13 +145,35 @@ else ;; esac - DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/download/$VERSION/$BINARY_NAME" + # If fallback URL is provided (e.g., from Pulse setup script), use it directly + if [[ -n "$FALLBACK_BASE" ]]; then + FALLBACK_URL="${FALLBACK_BASE%/}?arch=${ARCH_LABEL}" + print_info "Downloading $BINARY_NAME from Pulse server..." + if ! curl -fsSL "$FALLBACK_URL" -o "$BINARY_PATH.tmp"; then + print_error "Failed to download proxy binary from $FALLBACK_URL" + exit 1 + fi + else + # Fallback not provided, download from GitHub release + GITHUB_REPO="rcourtman/Pulse" + if [[ "$VERSION" == "latest" ]]; then + RELEASE_URL="https://api.github.com/repos/$GITHUB_REPO/releases/latest" + print_info "Fetching latest release info..." + RELEASE_DATA=$(curl -fsSL "$RELEASE_URL") + VERSION=$(echo "$RELEASE_DATA" | grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4) + if [[ -z "$VERSION" ]]; then + print_error "Failed to determine latest version" + exit 1 + fi + print_info "Latest version: $VERSION" + fi - # Download binary - print_info "Downloading $BINARY_NAME..." - if ! curl -fsSL "$DOWNLOAD_URL" -o "$BINARY_PATH.tmp"; then - print_error "Failed to download binary from $DOWNLOAD_URL" - exit 1 + DOWNLOAD_URL="https://github.com/$GITHUB_REPO/releases/download/$VERSION/$BINARY_NAME" + print_info "Downloading $BINARY_NAME from GitHub..." + if ! curl -fsSL "$DOWNLOAD_URL" -o "$BINARY_PATH.tmp"; then + print_error "Failed to download binary from $DOWNLOAD_URL" + exit 1 + fi fi # Make executable and move to final location @@ -177,6 +205,7 @@ After=network.target Type=simple User=pulse-sensor-proxy Group=pulse-sensor-proxy +SupplementaryGroups=www-data WorkingDirectory=/var/lib/pulse-sensor-proxy ExecStart=/usr/local/bin/pulse-sensor-proxy Restart=on-failure @@ -185,6 +214,7 @@ RestartSec=5s # Runtime dirs/sockets RuntimeDirectory=pulse-sensor-proxy RuntimeDirectoryMode=0775 +RuntimeDirectoryPreserve=yes UMask=0007 # Core hardening @@ -192,6 +222,7 @@ NoNewPrivileges=true ProtectSystem=strict ProtectHome=read-only ReadWritePaths=/var/lib/pulse-sensor-proxy +ReadOnlyPaths=/run/pve-cluster /etc/pve ProtectKernelTunables=true ProtectKernelModules=true ProtectControlGroups=true @@ -244,12 +275,133 @@ fi print_info "Socket ready at $SOCKET_PATH" +# Configure SSH keys for cluster temperature monitoring +print_info "Configuring proxy SSH access to cluster nodes..." + +# Wait for proxy to generate SSH keys +PROXY_KEY_FILE="$SSH_DIR/id_ed25519.pub" +for i in {1..10}; do + if [[ -f "$PROXY_KEY_FILE" ]]; then + break + fi + sleep 1 +done + +if [[ ! -f "$PROXY_KEY_FILE" ]]; then + print_error "Proxy SSH key not generated after 10 seconds" + print_info "Check service logs: journalctl -u pulse-sensor-proxy -n 50" + exit 1 +fi + +PROXY_PUBLIC_KEY=$(cat "$PROXY_KEY_FILE") +print_info "Proxy public key: ${PROXY_PUBLIC_KEY:0:50}..." + +# Discover cluster nodes +if command -v pvecm >/dev/null 2>&1; then + # Extract node IPs from pvecm status + CLUSTER_NODES=$(pvecm status 2>/dev/null | awk '/0x[0-9a-f]+.*[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/ {print $3}') + + if [[ -n "$CLUSTER_NODES" ]]; then + print_info "Discovered cluster nodes: $(echo $CLUSTER_NODES | tr '\n' ' ')" + + # Configure SSH key with forced command restriction + FORCED_CMD='command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' + AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY}" + + # Track SSH key push results + SSH_SUCCESS_COUNT=0 + SSH_FAILURE_COUNT=0 + declare -a SSH_FAILED_NODES=() + + # Push key to each cluster node + for node_ip in $CLUSTER_NODES; do + print_info "Authorizing proxy key on node $node_ip..." + + # Remove any existing proxy keys first + ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 root@"$node_ip" \ + "sed -i '/pulse-sensor-proxy\$/d' /root/.ssh/authorized_keys" 2>/dev/null || true + + # Add new key with forced command + SSH_ERROR=$(ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=5 root@"$node_ip" \ + "echo '${AUTH_LINE}' >> /root/.ssh/authorized_keys" 2>&1) + if [[ $? -eq 0 ]]; then + print_success "SSH key configured on $node_ip" + ((SSH_SUCCESS_COUNT++)) + else + print_warn "Failed to configure SSH key on $node_ip" + ((SSH_FAILURE_COUNT++)) + SSH_FAILED_NODES+=("$node_ip") + # Log detailed error for debugging + if [[ -n "$SSH_ERROR" ]]; then + print_info " Error details: $(echo "$SSH_ERROR" | head -1)" + fi + fi + done + + # Print summary + print_info "" + print_info "SSH key configuration summary:" + print_info " ✓ Success: $SSH_SUCCESS_COUNT node(s)" + if [[ $SSH_FAILURE_COUNT -gt 0 ]]; then + print_warn " ✗ Failed: $SSH_FAILURE_COUNT node(s) - ${SSH_FAILED_NODES[*]}" + print_info "" + print_info "To retry failed nodes, use Pulse's 'Ensure cluster keys' button or manually run:" + print_info " ssh root@ 'echo \"${AUTH_LINE}\" >> /root/.ssh/authorized_keys'" + fi + else + # No cluster found - configure standalone node + print_info "No cluster detected, configuring standalone node..." + + # Configure SSH key with forced command restriction + FORCED_CMD='command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' + AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY}" + + # Configure localhost + print_info "Authorizing proxy key on localhost..." + + # Remove any existing proxy keys first + sed -i '/pulse-sensor-proxy$/d' /root/.ssh/authorized_keys 2>/dev/null || touch /root/.ssh/authorized_keys + + # Add new key with forced command + if echo "${AUTH_LINE}" >> /root/.ssh/authorized_keys; then + print_success "SSH key configured on standalone node" + print_info "" + print_info "Standalone node configuration complete" + else + print_warn "Failed to configure SSH key on localhost" + print_info "Manually add this line to /root/.ssh/authorized_keys:" + print_info " ${AUTH_LINE}" + fi + fi +else + # Proxmox host but pvecm not available (shouldn't happen, but handle it) + print_warn "pvecm command not available" + print_info "Configuring SSH key for localhost..." + + # Configure localhost as fallback + FORCED_CMD='command="sensors -j",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' + AUTH_LINE="${FORCED_CMD} ${PROXY_PUBLIC_KEY}" + + sed -i '/pulse-sensor-proxy$/d' /root/.ssh/authorized_keys 2>/dev/null || touch /root/.ssh/authorized_keys + if echo "${AUTH_LINE}" >> /root/.ssh/authorized_keys; then + print_success "SSH key configured on localhost" + else + print_warn "Failed to configure SSH key" + fi +fi + # Ensure container mount via mp configuration print_info "Ensuring container socket mount configuration..." MOUNT_TARGET="/mnt/pulse-proxy" +LXC_CONFIG="/etc/pve/lxc/${CTID}.conf" CONFIG_CONTENT=$(pct config "$CTID") CURRENT_MP=$(pct config "$CTID" | awk -v target="$MOUNT_TARGET" '$1 ~ /^mp[0-9]+:$/ && index($0, "mp=" target) {split($1, arr, ":"); print arr[1]; exit}') MOUNT_UPDATED=false +HOTPLUG_FAILED=false +CT_RUNNING=false +if pct status "$CTID" 2>/dev/null | grep -q "running"; then + CT_RUNNING=true +fi if [[ -z "$CURRENT_MP" ]]; then for idx in $(seq 0 9); do @@ -263,16 +415,32 @@ if [[ -z "$CURRENT_MP" ]]; then exit 1 fi print_info "Configuring container mount using $CURRENT_MP..." - pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0" - MOUNT_UPDATED=true + if pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0" 2>/dev/null; then + MOUNT_UPDATED=true + else + HOTPLUG_FAILED=true + fi else print_info "Container already has socket mount configured ($CURRENT_MP)" - pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0" + if pct set "$CTID" -${CURRENT_MP} "/run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0" 2>/dev/null; then + MOUNT_UPDATED=true + else + HOTPLUG_FAILED=true + fi +fi + +if [[ "$HOTPLUG_FAILED" = true ]]; then + print_warn "Hot-plugging socket mount failed (container may be running). Updating config directly." + CURRENT_MP_LINE="${CURRENT_MP}: /run/pulse-sensor-proxy,mp=${MOUNT_TARGET},replicate=0" + if ! grep -q "^${CURRENT_MP}:" "$LXC_CONFIG" 2>/dev/null; then + echo "$CURRENT_MP_LINE" >> "$LXC_CONFIG" + else + sed -i "s#^${CURRENT_MP}:.*#${CURRENT_MP_LINE}#" "$LXC_CONFIG" + fi MOUNT_UPDATED=true fi # Remove legacy lxc.mount.entry directives if present -LXC_CONFIG="/etc/pve/lxc/${CTID}.conf" if grep -q "lxc.mount.entry: /run/pulse-sensor-proxy" "$LXC_CONFIG"; then print_info "Removing legacy lxc.mount.entry directives for pulse-sensor-proxy" sed -i '/lxc\.mount\.entry: \/run\/pulse-sensor-proxy/d' "$LXC_CONFIG" @@ -281,20 +449,28 @@ fi # Restart container to apply mount if configuration changed or mount missing if [[ "$MOUNT_UPDATED" = true ]]; then - print_info "Restarting container to apply socket mount..." - pct stop "$CTID" || true - sleep 2 - pct start "$CTID" - sleep 5 + if [[ "$CT_RUNNING" = true ]]; then + print_warn "Container $CTID is currently running. Restart it when convenient to activate the secure proxy mount." + else + print_info "Restarting container to apply socket mount..." + pct stop "$CTID" || true + sleep 2 + pct start "$CTID" + sleep 5 + fi fi # Verify socket directory and file inside container -print_info "Verifying socket accessibility..." -if pct exec "$CTID" -- test -S "${MOUNT_TARGET}/pulse-sensor-proxy.sock"; then - print_info "Socket is accessible in container at ${MOUNT_TARGET}/pulse-sensor-proxy.sock" +if [[ "$HOTPLUG_FAILED" = true && "$CT_RUNNING" = true ]]; then + print_warn "Skipping socket verification until container $CTID is restarted." else - print_warn "Socket not visible at ${MOUNT_TARGET}/pulse-sensor-proxy.sock" - print_info "Check container configuration and restart if necessary" + print_info "Verifying socket accessibility..." + if pct exec "$CTID" -- test -S "${MOUNT_TARGET}/pulse-sensor-proxy.sock"; then + print_info "Socket is accessible in container at ${MOUNT_TARGET}/pulse-sensor-proxy.sock" + else + print_warn "Socket not visible at ${MOUNT_TARGET}/pulse-sensor-proxy.sock" + print_info "Check container configuration and restart if necessary" + fi fi # Configure Pulse backend environment override inside container