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.
This commit is contained in:
rcourtman 2025-10-17 11:43:26 +00:00
parent 23714b33a1
commit f141f7db33
11 changed files with 2248 additions and 358 deletions

View file

@ -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")

View file

@ -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

View file

@ -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<SettingsProps> = (props) => {
// Diagnostics
const [diagnosticsData, setDiagnosticsData] = createSignal<DiagnosticsData | null>(null);
const [runningDiagnostics, setRunningDiagnostics] = createSignal(false);
const [proxyActionLoading, setProxyActionLoading] = createSignal<'register-nodes' | null>(null);
const [proxyRegisterSummary, setProxyRegisterSummary] = createSignal<ProxyRegisterNode[] | null>(null);
const [dockerActionLoading, setDockerActionLoading] = createSignal<string | null>(null);
const [dockerMigrationResults, setDockerMigrationResults] = createSignal<Record<string, DockerMigrationResult>>({});
// Security
const [securityStatus, setSecurityStatus] = createSignal<SecurityStatusInfo | null>(null);
@ -314,6 +414,144 @@ const Settings: Component<SettingsProps> = (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<Record<string, unknown>>).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<SettingsProps> = (props) => {
</p>
<button
type="button"
onClick={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);
}
onClick={() => {
void runDiagnostics();
}}
disabled={runningDiagnostics()}
class="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@ -3668,20 +3896,461 @@ const Settings: Component<SettingsProps> = (props) => {
</h5>
<div class="text-xs space-y-1 text-gray-600 dark:text-gray-400">
<div>Version: {diagnosticsData()?.version || 'Unknown'}</div>
<div>
Uptime: {Math.floor((diagnosticsData()?.uptime || 0) / 60)} minutes
</div>
<div>Runtime: {diagnosticsData()?.runtime || 'Unknown'}</div>
<div>
Memory:{' '}
{Math.round(
(diagnosticsData()?.system?.memory?.alloc || 0) / 1024 / 1024,
)}{' '}
Uptime: {formatUptime(diagnosticsData()?.uptime || 0)}
</div>
<div>
OS / Arch:{' '}
{diagnosticsData()?.system?.os
? `${diagnosticsData()?.system?.os} / ${diagnosticsData()?.system?.arch || 'Unknown'}`
: 'Unknown'}
</div>
<div>Go runtime: {diagnosticsData()?.system?.goVersion || 'Unknown'}</div>
<div>
CPU cores: {diagnosticsData()?.system?.numCPU ?? 'Unknown'}
</div>
<div>
Goroutines: {diagnosticsData()?.system?.numGoroutine ?? 'Unknown'}
</div>
<div>
Memory: {diagnosticsData()?.system?.memoryMB ?? 0}{' '}
MB
</div>
</div>
</Card>
{/* Temperature proxy guidance */}
<Show when={diagnosticsData()?.temperatureProxy}>
{(temp) => (
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Temperature proxy
</h5>
<div class="text-xs space-y-1 text-gray-600 dark:text-gray-400">
<div class="flex items-center justify-between">
<span>Proxy socket</span>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${
temp().socketFound ? 'bg-green-500' : 'bg-red-500'
}`}
>
{temp().socketFound ? 'Detected' : 'Missing'}
</span>
</div>
<div class="flex items-center justify-between">
<span>Daemon</span>
<span
class={`px-2 py-0.5 rounded text-white text-xs ${
temp().proxyReachable ? 'bg-green-500' : 'bg-yellow-500'
}`}
>
{temp().proxyReachable ? 'Responding' : 'No response'}
</span>
</div>
<Show when={temp().socketPath}>
<div>Socket path: {temp().socketPath}</div>
</Show>
<Show when={temp().socketPermissions}>
<div>Permissions: {temp().socketPermissions}</div>
</Show>
<Show when={temp().socketOwner || temp().socketGroup}>
<div>
Owner:{' '}
{[temp().socketOwner, temp().socketGroup]
.filter(Boolean)
.join(' / ') || 'Unknown'}
</div>
</Show>
<Show when={temp().proxyVersion}>
<div>Proxy version: {temp().proxyVersion}</div>
</Show>
<Show when={temp().proxySshDirectory}>
<div>SSH directory: {temp().proxySshDirectory}</div>
</Show>
<Show when={temp().proxyPublicKeySha256}>
<div>Key fingerprint: {temp().proxyPublicKeySha256}</div>
</Show>
<Show when={typeof temp().legacySshKeyCount === 'number'}>
<div>
Legacy SSH keys:{' '}
{temp().legacySshKeyCount ?? 0}
</div>
</Show>
<Show when={temp().legacySSHDetected}>
<div class="text-red-500">
Legacy SSH temperature collection detected
</div>
</Show>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
if (!proxyActionLoading()) {
void handleRegisterProxyNodes();
}
}}
disabled={proxyActionLoading() !== null}
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{proxyActionLoading() === 'register-nodes'
? 'Checking nodes...'
: 'Check proxy nodes'}
</button>
</div>
<Show when={proxyRegisterSummary() && proxyRegisterSummary()!.length > 0}>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div>Proxy node connectivity:</div>
<ul class="list-disc pl-4 space-y-0.5">
<For each={proxyRegisterSummary() || []}>
{(node) => (
<li>
<span class={node.sshReady ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
{node.name}: {node.sshReady ? 'reachable' : 'unreachable'}
</span>
<Show when={node.error}>
<span class="ml-1 text-gray-500 dark:text-gray-400">
({node.error})
</span>
</Show>
</li>
)}
</For>
</ul>
</div>
</Show>
<Show when={temp().notes && temp().notes!.length > 0}>
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
<For each={temp().notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
)}
</Show>
{/* API token adoption */}
<Show when={diagnosticsData()?.apiTokens}>
{(apiDiag) => (
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
API tokens
</h5>
<div class="text-xs space-y-2 text-gray-600 dark:text-gray-400">
<div class="flex flex-wrap gap-2">
<span
class={`px-2 py-0.5 rounded text-xs ${
apiDiag().enabled ? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
}`}
>
{apiDiag().enabled ? 'Token auth enabled' : 'Token auth disabled'}
</span>
<Show when={apiDiag().hasEnvTokens}>
<span class="px-2 py-0.5 rounded text-xs bg-orange-100 text-orange-700 dark:bg-orange-700/40 dark:text-orange-100">
Env override detected
</span>
</Show>
<Show when={apiDiag().hasLegacyToken}>
<span class="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700 dark:bg-red-700/40 dark:text-red-100">
Legacy token present
</span>
</Show>
</div>
<div class="grid grid-cols-2 gap-2">
<div>Configured tokens: {apiDiag().tokenCount}</div>
<div>
Rotation needed:{' '}
{apiDiag().recommendTokenRotation ? 'Yes' : 'No'}
</div>
<div>
Docker hosts on shared token:{' '}
{apiDiag().legacyDockerHostCount ?? 0}
</div>
<div>
Unused tokens:{' '}
{apiDiag().unusedTokenCount ?? 0}
</div>
</div>
</div>
<Show when={apiDiag().tokens && apiDiag().tokens!.length > 0}>
<div class="mt-3 border border-gray-200 dark:border-gray-700 rounded-md divide-y divide-gray-200 dark:divide-gray-700">
<For each={apiDiag().tokens || []}>
{(token) => (
<div class="p-2 text-xs text-gray-600 dark:text-gray-400 flex flex-wrap justify-between gap-2">
<div class="flex-1 min-w-[140px]">
<div class="font-medium text-gray-700 dark:text-gray-200">
{token.name || 'Unnamed token'}
</div>
<div class="text-2xs text-gray-500 dark:text-gray-400">
{token.hint || 'No hint available'}
<Show when={token.source}>
<span class="ml-2 uppercase tracking-wide">
({token.source})
</span>
</Show>
</div>
</div>
<div class="text-right min-w-[160px]">
<div>
Created:{' '}
{token.createdAt
? new Date(token.createdAt).toLocaleString()
: 'Unknown'}
</div>
<div>
Last used:{' '}
{token.lastUsedAt
? formatRelativeTime(
new Date(token.lastUsedAt).getTime(),
)
: 'Never'}
</div>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={apiDiag().usage && apiDiag().usage!.length > 0}>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div class="font-semibold text-gray-700 dark:text-gray-200">Token usage</div>
<ul class="list-disc pl-4 space-y-1">
<For each={apiDiag().usage || []}>
{(usage) => (
<li>
{usage.tokenId}: {usage.hostCount}{' '}
{usage.hostCount === 1 ? 'host' : 'hosts'}
<Show when={usage.hosts && usage.hosts!.length > 0}>
<span class="ml-1 text-gray-500 dark:text-gray-400">
({usage.hosts!.join(', ')})
</span>
</Show>
</li>
)}
</For>
</ul>
</div>
</Show>
<Show when={apiDiag().notes && apiDiag().notes!.length > 0}>
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
<For each={apiDiag().notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
)}
</Show>
{/* Docker agent adoption */}
<Show when={diagnosticsData()?.dockerAgents}>
{(dockerDiag) => (
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Docker agents
</h5>
<Show when={dockerDiag().hostsTotal > 0}>
<div class="text-xs text-gray-600 dark:text-gray-400 grid grid-cols-2 gap-2">
<div>Total hosts: {dockerDiag().hostsTotal}</div>
<div>Online: {dockerDiag().hostsOnline}</div>
<div>
With dedicated tokens: {dockerDiag().hostsWithTokenBinding}
</div>
<div>
Attention required: {dockerDiag().hostsNeedingAttention}
</div>
<div>
Missing version: {dockerDiag().hostsWithoutVersion ?? 0}
</div>
<div>
Outdated agents: {dockerDiag().hostsOutdatedVersion ?? 0}
</div>
<div>
Stale commands: {dockerDiag().hostsWithStaleCommand ?? 0}
</div>
<div>
Pending uninstall: {dockerDiag().hostsPendingUninstall ?? 0}
</div>
</div>
<Show when={dockerDiag().recommendedAgentVersion}>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Recommended agent version: {dockerDiag().recommendedAgentVersion}
</div>
</Show>
</Show>
<Show when={dockerDiag().attention && dockerDiag().attention!.length > 0}>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<For each={dockerDiag().attention || []}>
{(entry) => (
<div class="p-2 space-y-1">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-700 dark:text-gray-200">
{entry.name}
</span>
<span
class={`px-2 py-0.5 rounded text-xs ${
entry.status === 'online'
? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
: 'bg-red-100 text-red-700 dark:bg-red-700/40 dark:text-red-100'
}`}
>
{entry.status || 'unknown'}
</span>
</div>
<div class="text-2xs text-gray-500 dark:text-gray-400 space-x-2">
<Show when={entry.agentVersion}>
<span>Agent {entry.agentVersion}</span>
</Show>
<Show when={entry.tokenHint}>
<span>Token {entry.tokenHint}</span>
</Show>
<Show when={entry.lastSeen}>
<span>
Seen{' '}
{formatRelativeTime(
new Date(entry.lastSeen!).getTime(),
)}
</span>
</Show>
</div>
<ul class="list-disc pl-4 space-y-1 text-gray-600 dark:text-gray-400">
<For each={entry.issues}>
{(issue) => <li>{issue}</li>}
</For>
</ul>
<div class="mt-2 flex flex-wrap gap-2">
<button
type="button"
onClick={() => {
if (dockerActionLoading() !== entry.hostId) {
void handleDockerPrepareToken(entry.hostId);
}
}}
disabled={dockerActionLoading() === entry.hostId}
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{dockerActionLoading() === entry.hostId
? 'Preparing token...'
: 'Generate dedicated token'}
</button>
</div>
<Show when={dockerMigrationResults()[entry.hostId]}>
<div class="mt-3 w-full space-y-2 bg-gray-100 dark:bg-gray-800/60 border border-gray-200 dark:border-gray-700 rounded p-3 text-xs text-gray-700 dark:text-gray-200">
{(() => {
const migration = dockerMigrationResults()[entry.hostId]!;
return (
<>
<div class="font-semibold text-gray-800 dark:text-gray-100">
Install command
</div>
<pre class="whitespace-pre-wrap break-all bg-gray-900/80 text-gray-100 rounded p-2 text-[11px]">
{migration.installCommand}
</pre>
<button
type="button"
class="px-2 py-1 text-xs bg-slate-700 text-white rounded hover:bg-slate-800 transition-colors"
onClick={() => void handleCopy(migration.installCommand, 'Install command copied')}
>
Copy install command
</button>
<div class="font-semibold text-gray-800 dark:text-gray-100 mt-2">
Systemd snippet
</div>
<pre class="whitespace-pre-wrap break-all bg-gray-900/80 text-gray-100 rounded p-2 text-[11px]">
{migration.systemdServiceSnippet}
</pre>
<button
type="button"
class="px-2 py-1 text-xs bg-slate-700 text-white rounded hover:bg-slate-800 transition-colors"
onClick={() => void handleCopy(migration.systemdServiceSnippet, 'Service snippet copied')}
>
Copy service snippet
</button>
<div class="text-gray-600 dark:text-gray-400">
Target URL: {migration.pulseURL}
</div>
</>
);
})()}
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show when={dockerDiag().notes && dockerDiag().notes!.length > 0}>
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
<For each={dockerDiag().notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
)}
</Show>
{/* Alerts configuration */}
<Show when={diagnosticsData()?.alerts}>
{(alerts) => (
<Card padding="sm">
<h5 class="text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300">
Alerts configuration
</h5>
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="flex flex-wrap gap-2">
<span
class={`px-2 py-0.5 rounded text-xs ${
alerts().legacyThresholdsDetected
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
}`}
>
Legacy thresholds {alerts().legacyThresholdsDetected ? 'detected' : 'migrated'}
</span>
<span
class={`px-2 py-0.5 rounded text-xs ${
alerts().missingCooldown
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
}`}
>
Cooldown {alerts().missingCooldown ? 'missing' : 'configured'}
</span>
<span
class={`px-2 py-0.5 rounded text-xs ${
alerts().missingGroupingWindow
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
}`}
>
Grouping window {alerts().missingGroupingWindow ? 'disabled' : 'enabled'}
</span>
</div>
<Show when={alerts().legacyThresholdSources && alerts().legacyThresholdSources!.length > 0}>
<div>
Legacy sources: {alerts().legacyThresholdSources!.join(', ')}
</div>
</Show>
<Show when={alerts().legacyScheduleSettings && alerts().legacyScheduleSettings!.length > 0}>
<div>
Legacy schedule settings: {alerts().legacyScheduleSettings!.join(', ')}
</div>
</Show>
</div>
<Show when={alerts().notes && alerts().notes!.length > 0}>
<ul class="mt-3 text-xs text-gray-600 dark:text-gray-400 list-disc pl-4 space-y-1">
<For each={alerts().notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
)}
</Show>
{/* Nodes Status */}
<Show
when={diagnosticsData()?.nodes && diagnosticsData()!.nodes.length > 0}
@ -4078,6 +4747,109 @@ const Settings: Component<SettingsProps> = (props) => {
);
}
if (
sanitized.temperatureProxy &&
typeof sanitized.temperatureProxy === 'object'
) {
const proxyDiag = sanitized.temperatureProxy as Record<string, unknown>;
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<string, unknown>;
const tokenIdMap = new Map<string, string>();
if (Array.isArray(apiTokens.tokens)) {
apiTokens.tokens = (apiTokens.tokens as Array<Record<string, unknown>>).map(
(token, tokenIndex: number) => {
const sanitizedToken = { ...token } as Record<string, unknown>;
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<Record<string, unknown>>).map(
(usage, usageIndex: number) => {
const sanitizedUsage = { ...usage } as Record<string, unknown>;
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<string>).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<string, unknown>;
if (Array.isArray(dockerDiag.attention)) {
dockerDiag.attention = (
dockerDiag.attention as Array<Record<string, unknown>>
).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<string, unknown>;
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 &&

View file

@ -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

View file

@ -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/tty 2>/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/tty 2>/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/tty 2>/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/tty 2>/dev/null; then
if read -n 1 -r SSH_REPLY </dev/tty 2>/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/tty 2>/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

View file

@ -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{

View file

@ -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 ""

View file

@ -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

View file

@ -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)

View file

@ -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)"
}

View file

@ -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 <container-id>"
echo "Usage: $0 --ctid <container-id> [--version <version>] [--local-binary <path>]"
echo "Usage: $0 --ctid <container-id> [--pulse-server <url>] [--version <version>] [--local-binary <path>]"
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@<node> '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