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) => {