Improve temperature proxy workflow

This commit is contained in:
rcourtman 2025-11-17 14:25:46 +00:00
parent 430c98c5ef
commit a479040651
17 changed files with 1217 additions and 34 deletions

View file

@ -125,6 +125,12 @@ Your infrastructure data is yours alone.
| Kubernetes/Helm | Clusters needing HA, ingress, GitOps | Kubernetes cluster with storage class + Helm 3 | [docs/KUBERNETES.md](docs/KUBERNETES.md) | | Kubernetes/Helm | Clusters needing HA, ingress, GitOps | Kubernetes cluster with storage class + Helm 3 | [docs/KUBERNETES.md](docs/KUBERNETES.md) |
| Bare metal/systemd | Minimal installs or environments without containers | Go-supported Linux host, systemd access | [docs/INSTALL.md](docs/INSTALL.md) and `scripts/build-release.sh` | | Bare metal/systemd | Minimal installs or environments without containers | Go-supported Linux host, systemd access | [docs/INSTALL.md](docs/INSTALL.md) and `scripts/build-release.sh` |
### Bootstrap vs Node Setup
- Run `install.sh` on the Proxmox host to create the Pulse LXC and (optionally) install `pulse-sensor-proxy`. The installer records the decision in `/etc/pulse/install_summary.json`.
- After Pulse is running, head to **Settings → Nodes** and run the Quick Setup script per PVE/PBS/PMG instance. The script reads the summary so it can skip redundant prompts when the host proxy already exists, and it only asks you to deploy HTTPS proxies for remote/standalone nodes.
- If you skipped the proxy during bootstrap, the Quick Setup script (and the Settings UI) now remind you and provide a copy/paste HTTPS installer command so you can enable temperatures later.
## Quick Start ## Quick Start
### Install ### Install

View file

@ -181,6 +181,10 @@ When Pulse cannot share the `/run/pulse-sensor-proxy` socket (for example, you r
This HTTP path complements the socket path—you can run both simultaneously. Containerised Pulse stacks still need the socket for their own host, while HTTP mode covers every additional Proxmox node on the LAN or across sites. This HTTP path complements the socket path—you can run both simultaneously. Containerised Pulse stacks still need the socket for their own host, while HTTP mode covers every additional Proxmox node on the LAN or across sites.
Pulse now isolates transport failures per node: when a proxy reports that a node is invalid or unreachable, Pulse cools down polling for that node only instead of tearing down the shared socket. You will see a cooldown note in the diagnostics card if a node keeps failing; fix the proxy or disable temperature monitoring for that node to resume collection.
> **Tip:** When Pulse is running inside a container and temperatures are blocked, open **Settings → Nodes → Edit node → Temperature monitoring**. The UI now offers a one-click “Generate HTTPS proxy command” button that produces the exact `install-sensor-proxy.sh --standalone --http-mode --pulse-server …` command for that node, so you can copy it straight to the host shell without rebuilding the instructions manually.
--- ---
## Disable Temperature Monitoring ## Disable Temperature Monitoring
@ -359,6 +363,8 @@ When run on a Proxmox host with Pulse in an LXC container:
5. Sets up SSH keys and cluster discovery 5. Sets up SSH keys and cluster discovery
6. **Fully turnkey - no manual steps required!** 6. **Fully turnkey - no manual steps required!**
> **Note:** The main `install.sh` already installs the host-side proxy when you opt-in during bootstrap, so the Quick Setup script simply verifies it and moves on—you wont be prompted a second time. Remote/standalone nodes still prompt to deploy their own HTTPS proxy.
### For Docker Deployments (Manual Steps Required) ### For Docker Deployments (Manual Steps Required)
When Pulse runs in Docker, the setup script will show you manual steps: When Pulse runs in Docker, the setup script will show you manual steps:

View file

@ -10,8 +10,15 @@ type NodeConfigWithStatus = NodeConfig & {
export interface TemperatureTransportInfo { export interface TemperatureTransportInfo {
httpMap: Record<string, { reachable: boolean; error?: string; url?: string }>; httpMap: Record<string, { reachable: boolean; error?: string; url?: string }>;
socketStatus: 'healthy' | 'error' | 'missing'; socketStatus: 'healthy' | 'error' | 'missing';
socketCooldowns?: Record<string, TemperatureSocketCooldownInfo>;
} }
type TemperatureSocketCooldownInfo = {
secondsRemaining?: number;
until?: string;
lastError?: string;
};
interface PveNodesTableProps { interface PveNodesTableProps {
nodes: NodeConfigWithStatus[]; nodes: NodeConfigWithStatus[];
stateNodes: { instance: string; status?: string; connectionHealth?: string }[]; stateNodes: { instance: string; status?: string; connectionHealth?: string }[];
@ -30,6 +37,39 @@ type TemperatureTransportBadge = {
description?: string; description?: string;
}; };
const normalizeHostKey = (value?: string) => {
if (!value) {
return '';
}
let result = value.trim().toLowerCase();
if (!result) {
return '';
}
result = result.replace(/^https?:\/\//, '');
const slashIndex = result.indexOf('/');
if (slashIndex !== -1) {
result = result.slice(0, slashIndex);
}
const colonIndex = result.indexOf(':');
if (colonIndex !== -1) {
result = result.slice(0, colonIndex);
}
return result;
};
const formatCooldown = (seconds?: number) => {
if (!seconds || seconds <= 0) {
return '0s';
}
if (seconds >= 3600) {
return `${Math.round(seconds / 3600)}h`;
}
if (seconds >= 60) {
return `${Math.round(seconds / 60)}m`;
}
return `${Math.round(seconds)}s`;
};
const STATUS_META: Record<string, StatusMeta> = { const STATUS_META: Record<string, StatusMeta> = {
online: { online: {
dotClass: 'bg-green-500', dotClass: 'bg-green-500',
@ -65,6 +105,11 @@ const resolveTemperatureTransport = (
): TemperatureTransportBadge => { ): TemperatureTransportBadge => {
const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled); const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled);
const normalizedTransport = (node.temperatureTransport || '').toLowerCase(); const normalizedTransport = (node.temperatureTransport || '').toLowerCase();
const nodeKey = normalizeHostKey(node.name);
const hostKey = normalizeHostKey(node.host);
const socketCooldownEntry =
(nodeKey && info?.socketCooldowns?.[nodeKey]) ||
(hostKey && info?.socketCooldowns?.[hostKey]);
if (!monitoringEnabled) { if (!monitoringEnabled) {
return { return {
label: 'Temp disabled', label: 'Temp disabled',
@ -78,11 +123,21 @@ const resolveTemperatureTransport = (
}; };
} }
const key = (node.name || '').toLowerCase(); const key = nodeKey;
const httpEntry = info?.httpMap?.[key]; const httpEntry = key ? info?.httpMap?.[key] : undefined;
const socketStatus = info?.socketStatus; const socketStatus = info?.socketStatus;
const buildSocketBadge = (): TemperatureTransportBadge => { const buildSocketBadge = (): TemperatureTransportBadge => {
if (socketCooldownEntry) {
const retryText = `Retrying in ${formatCooldown(socketCooldownEntry.secondsRemaining)}`;
return {
label: 'Socket cooldown',
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300',
description: socketCooldownEntry.lastError
? `${socketCooldownEntry.lastError} (${retryText})`
: retryText,
};
}
if (socketStatus === 'error') { if (socketStatus === 'error') {
return { return {
label: 'Socket error', label: 'Socket error',

View file

@ -1,4 +1,4 @@
import { Component, Show, For, createSignal, createEffect } from 'solid-js'; import { Component, Show, For, createSignal, createEffect, createMemo } from 'solid-js';
import { Portal } from 'solid-js/web'; import { Portal } from 'solid-js/web';
import type { NodeConfig } from '@/types/nodes'; import type { NodeConfig } from '@/types/nodes';
import type { SecurityStatus } from '@/types/config'; import type { SecurityStatus } from '@/types/config';
@ -6,6 +6,7 @@ import { copyToClipboard } from '@/utils/clipboard';
import { showSuccess, showError } from '@/utils/toast'; import { showSuccess, showError } from '@/utils/toast';
import { getPulseBaseUrl } from '@/utils/url'; import { getPulseBaseUrl } from '@/utils/url';
import { NodesAPI } from '@/api/nodes'; import { NodesAPI } from '@/api/nodes';
import { apiFetchJSON } from '@/utils/apiClient';
import { SectionHeader } from '@/components/shared/SectionHeader'; import { SectionHeader } from '@/components/shared/SectionHeader';
import { import {
formField, formField,
@ -33,6 +34,12 @@ interface NodeModalProps {
onToggleTemperatureMonitoring?: (enabled: boolean) => Promise<void> | void; onToggleTemperatureMonitoring?: (enabled: boolean) => Promise<void> | void;
} }
type TemperatureTransportDetail = {
tone: 'info' | 'success' | 'warning' | 'danger';
message: string;
disable?: boolean;
};
const deriveNameFromHost = (host: string): string => { const deriveNameFromHost = (host: string): string => {
let value = host.trim(); let value = host.trim();
if (!value) { if (!value) {
@ -85,9 +92,97 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const [quickSetupCommand, setQuickSetupCommand] = createSignal(''); const [quickSetupCommand, setQuickSetupCommand] = createSignal('');
const [quickSetupToken, setQuickSetupToken] = createSignal(''); const [quickSetupToken, setQuickSetupToken] = createSignal('');
const [quickSetupExpiry, setQuickSetupExpiry] = createSignal<number | null>(null); const [quickSetupExpiry, setQuickSetupExpiry] = createSignal<number | null>(null);
const [proxyInstallCommand, setProxyInstallCommand] = createSignal('');
const [loadingProxyCommand, setLoadingProxyCommand] = createSignal(false);
const [proxyCommandError, setProxyCommandError] = createSignal<string | null>(null);
const showTemperatureMonitoringSection = () => const showTemperatureMonitoringSection = () =>
typeof props.temperatureMonitoringEnabled === 'boolean'; typeof props.temperatureMonitoringEnabled === 'boolean';
const temperatureMonitoringEnabledValue = () => props.temperatureMonitoringEnabled ?? true; const temperatureMonitoringEnabledValue = () => props.temperatureMonitoringEnabled ?? true;
const temperatureTransportDetail = createMemo<TemperatureTransportDetail | null>(() => {
const transport = props.editingNode?.temperatureTransport;
if (!transport) {
return null;
}
switch (transport.toLowerCase()) {
case 'socket-proxy':
return {
tone: 'success',
message: 'Temperatures flow through the host sensor proxy mounted at /run/pulse-sensor-proxy.',
};
case 'https-proxy':
return {
tone: 'success',
message: 'Temperatures are collected via the HTTPS proxy registered for this node.',
};
case 'ssh-blocked':
return {
tone: 'danger',
disable: true,
message:
'Pulse is running in a container without the pulse-sensor-proxy bind mount. Install the proxy on the host or register an HTTPS proxy before enabling temperatures.',
};
case 'ssh':
return {
tone: 'info',
message: 'Pulse will SSH directly into this node for temperature collection.',
};
default:
return null;
}
});
const temperatureToggleDisabled = () =>
props.temperatureMonitoringLocked ||
props.savingTemperatureSetting ||
Boolean(temperatureTransportDetail()?.disable);
const temperatureTransportMessageClass = () => {
const tone = temperatureTransportDetail()?.tone ?? 'info';
switch (tone) {
case 'success':
return 'text-green-600 dark:text-green-300';
case 'warning':
return 'text-amber-600 dark:text-amber-300';
case 'danger':
return 'text-red-600 dark:text-red-300';
default:
return 'text-gray-600 dark:text-gray-400';
}
};
const temperatureToggleTitle = () => {
const detail = temperatureTransportDetail();
if (detail?.disable) {
return detail.message;
}
return undefined;
};
const shouldOfferProxyCommand = () =>
props.nodeType === 'pve' && Boolean(props.editingNode?.id) && Boolean(temperatureTransportDetail()?.disable);
const fetchProxyInstallCommand = async () => {
if (loadingProxyCommand()) {
return;
}
setLoadingProxyCommand(true);
setProxyCommandError(null);
setProxyInstallCommand('');
try {
const nodeName = props.editingNode?.name ? encodeURIComponent(props.editingNode!.name) : '';
const query = nodeName ? `?node=${nodeName}` : '';
const response = await apiFetchJSON(`/api/temperature-proxy/install-command${query}`);
if (!response || typeof response.command !== 'string') {
throw new Error('Proxy installer command unavailable');
}
setProxyInstallCommand(response.command);
showSuccess('HTTPS proxy command ready', 2000);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to generate HTTPS proxy command';
setProxyCommandError(message);
showError(message);
logger.error('Failed to load proxy install command', error);
} finally {
setLoadingProxyCommand(false);
}
};
const quickSetupExpiryLabel = () => { const quickSetupExpiryLabel = () => {
const expiry = quickSetupExpiry(); const expiry = quickSetupExpiry();
if (!expiry) { if (!expiry) {
@ -1781,7 +1876,8 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
onChange={(event) => { onChange={(event) => {
props.onToggleTemperatureMonitoring?.(event.currentTarget.checked); props.onToggleTemperatureMonitoring?.(event.currentTarget.checked);
}} }}
disabled={props.temperatureMonitoringLocked || props.savingTemperatureSetting} disabled={temperatureToggleDisabled()}
title={temperatureToggleTitle()}
ariaLabel={ ariaLabel={
temperatureMonitoringEnabledValue() temperatureMonitoringEnabledValue()
? 'Disable temperature monitoring' ? 'Disable temperature monitoring'
@ -1789,6 +1885,58 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
} }
/> />
</div> </div>
<Show when={temperatureTransportDetail()}>
<p class={`mt-2 text-xs ${temperatureTransportMessageClass()}`}>
{temperatureTransportDetail()?.message}
</p>
</Show>
<Show when={shouldOfferProxyCommand()}>
<div class="mt-3 rounded border border-blue-200 bg-blue-50 p-2 text-xs text-blue-800 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-100 space-y-2">
<div class="font-semibold">Install HTTPS proxy on this host</div>
<div>Generate a one-line installer command to run on the Proxmox host:</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded bg-blue-600 px-2 py-1 text-white hover:bg-blue-700 disabled:opacity-70"
onClick={() => void fetchProxyInstallCommand()}
disabled={loadingProxyCommand()}
>
{loadingProxyCommand() ? 'Generating…' : 'Generate command'}
</button>
<a
href="/api/install/install-sensor-proxy.sh"
target="_blank"
rel="noreferrer"
class="rounded border border-blue-400 px-2 py-1 text-blue-700 hover:bg-blue-100 dark:text-blue-200 dark:border-blue-300 dark:hover:bg-blue-900/40"
>
Download installer script
</a>
</div>
<Show when={proxyCommandError()}>
<p class="text-xs text-red-600 dark:text-red-300">
{proxyCommandError()}
</p>
</Show>
<Show when={proxyInstallCommand()}>
<pre class="overflow-x-auto rounded bg-white/70 px-2 py-1 font-mono text-[0.65rem] text-gray-800 dark:bg-gray-900/40 dark:text-gray-200">
{proxyInstallCommand()}
</pre>
<button
type="button"
class="rounded bg-blue-600 px-2 py-1 text-white hover:bg-blue-700"
onClick={() => {
const command = proxyInstallCommand();
if (command) {
void copyToClipboard(command);
showSuccess('Proxy installer command copied');
}
}}
>
Copy command
</button>
</Show>
</div>
</Show>
<Show when={!temperatureMonitoringEnabledValue()}> <Show when={!temperatureMonitoringEnabledValue()}>
<p class="mt-3 rounded border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-200"> <p class="mt-3 rounded border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-200">
Pulse will skip SSH temperature polling for this node. Existing dashboard readings will stop refreshing. Pulse will skip SSH temperature polling for this node. Existing dashboard readings will stop refreshing.

View file

@ -17,6 +17,7 @@ import { getPulsePort, getPulseWebSocketUrl } from '@/utils/url';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { import {
apiFetch, apiFetch,
apiFetchJSON,
clearApiToken as clearApiClientToken, clearApiToken as clearApiClientToken,
getApiToken as getApiClientToken, getApiToken as getApiClientToken,
setApiToken as setApiClientToken, setApiToken as setApiClientToken,
@ -147,6 +148,38 @@ interface TemperatureProxyControlPlaneState {
status?: string; status?: string;
} }
interface TemperatureProxySocketHost {
node?: string;
host?: string;
cooldownUntil?: string;
secondsRemaining?: number;
lastError?: string;
}
type TemperatureSocketCooldownInfo = {
secondsRemaining?: number;
until?: string;
lastError?: string;
};
interface HostProxySummary {
requested?: boolean;
installed?: boolean;
hostSocketPresent?: boolean;
containerSocketPresent?: boolean | null;
lastUpdated?: string;
ctid?: string;
}
interface HostProxyStatusResponse {
hostSocketPresent?: boolean;
containerSocketPresent?: boolean;
summary?: HostProxySummary | null;
reinstallCommand?: string;
installerURL?: string;
lastChecked?: string;
}
interface TemperatureProxyDiagnostic { interface TemperatureProxyDiagnostic {
legacySSHDetected: boolean; legacySSHDetected: boolean;
recommendProxyUpgrade: boolean; recommendProxyUpgrade: boolean;
@ -165,6 +198,7 @@ interface TemperatureProxyDiagnostic {
httpProxies?: TemperatureProxyHTTPStatus[]; httpProxies?: TemperatureProxyHTTPStatus[];
controlPlaneEnabled?: boolean; controlPlaneEnabled?: boolean;
controlPlaneStates?: TemperatureProxyControlPlaneState[]; controlPlaneStates?: TemperatureProxyControlPlaneState[];
socketHostCooldowns?: TemperatureProxySocketHost[];
} }
interface APITokenSummary { interface APITokenSummary {
@ -588,6 +622,7 @@ const Settings: Component<SettingsProps> = (props) => {
const [envOverrides, setEnvOverrides] = createSignal<Record<string, boolean>>({}); const [envOverrides, setEnvOverrides] = createSignal<Record<string, boolean>>({});
const [temperatureMonitoringEnabled, setTemperatureMonitoringEnabled] = createSignal(true); const [temperatureMonitoringEnabled, setTemperatureMonitoringEnabled] = createSignal(true);
const [savingTemperatureSetting, setSavingTemperatureSetting] = createSignal(false); const [savingTemperatureSetting, setSavingTemperatureSetting] = createSignal(false);
const [hostProxyStatus, setHostProxyStatus] = createSignal<HostProxyStatusResponse | null>(null);
const temperatureMonitoringLocked = () => const temperatureMonitoringLocked = () =>
Boolean( Boolean(
envOverrides().temperatureMonitoringEnabled || envOverrides()['ENABLE_TEMPERATURE_MONITORING'], envOverrides().temperatureMonitoringEnabled || envOverrides()['ENABLE_TEMPERATURE_MONITORING'],
@ -850,6 +885,26 @@ const Settings: Component<SettingsProps> = (props) => {
return `${Math.floor(seconds)}s`; return `${Math.floor(seconds)}s`;
}; };
const normalizeHostKey = (value?: string | null) => {
if (!value) {
return '';
}
let result = value.trim().toLowerCase();
if (!result) {
return '';
}
result = result.replace(/^https?:\/\//, '');
const slashIndex = result.indexOf('/');
if (slashIndex !== -1) {
result = result.slice(0, slashIndex);
}
const colonIndex = result.indexOf(':');
if (colonIndex !== -1) {
result = result.slice(0, colonIndex);
}
return result;
};
const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => { const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => {
if (!diag?.temperatureProxy) { if (!diag?.temperatureProxy) {
return; return;
@ -872,6 +927,15 @@ const Settings: Component<SettingsProps> = (props) => {
showWarning(`Temperature proxy control plane is behind on: ${names}`); showWarning(`Temperature proxy control plane is behind on: ${names}`);
} }
} }
if (diag.temperatureProxy.socketHostCooldowns) {
const cooling = (diag.temperatureProxy.socketHostCooldowns as TemperatureProxySocketHost[]).filter(
(entry) => entry && (entry.node || entry.host),
);
if (cooling.length > 0) {
const hosts = cooling.map((entry) => entry.node || entry.host || 'proxy').join(', ');
showWarning(`Temperature proxy is cooling down the following hosts: ${hosts}`);
}
}
}; };
const temperatureTransportInfo = createMemo<TemperatureTransportInfo | null>(() => { const temperatureTransportInfo = createMemo<TemperatureTransportInfo | null>(() => {
@ -901,7 +965,20 @@ const Settings: Component<SettingsProps> = (props) => {
: diag.temperatureProxy.socketFound : diag.temperatureProxy.socketFound
? 'error' ? 'error'
: 'missing'; : 'missing';
return { httpMap, socketStatus }; const cooldowns: Record<string, TemperatureSocketCooldownInfo> = {};
const socketHosts = diag.temperatureProxy.socketHostCooldowns || [];
(socketHosts as TemperatureProxySocketHost[]).forEach((entry) => {
const key = normalizeHostKey(entry.node) || normalizeHostKey(entry.host);
if (!key) {
return;
}
cooldowns[key] = {
secondsRemaining: entry.secondsRemaining,
until: entry.cooldownUntil,
lastError: entry.lastError || undefined,
};
});
return { httpMap, socketStatus, socketCooldowns: cooldowns };
}); });
const proxyNodeChecksSupported = createMemo(() => { const proxyNodeChecksSupported = createMemo(() => {
@ -921,6 +998,15 @@ const Settings: Component<SettingsProps> = (props) => {
const diag = await response.json(); const diag = await response.json();
setDiagnosticsData(diag); setDiagnosticsData(diag);
emitTemperatureProxyWarnings(diag); emitTemperatureProxyWarnings(diag);
if (diag?.temperatureProxy?.hostProxySummary) {
setHostProxyStatus({
hostSocketPresent: Boolean(diag.temperatureProxy?.socketFound),
containerSocketPresent: Boolean(
diag.temperatureProxy?.hostProxySummary?.containerSocketPresent ?? false,
),
summary: diag.temperatureProxy?.hostProxySummary ?? undefined,
});
}
} catch (err) { } catch (err) {
logger.error('Failed to fetch diagnostics', err); logger.error('Failed to fetch diagnostics', err);
showError('Failed to run diagnostics'); showError('Failed to run diagnostics');
@ -929,6 +1015,40 @@ const Settings: Component<SettingsProps> = (props) => {
} }
}; };
const refreshHostProxyStatus = async (notify = false) => {
try {
const status = (await apiFetchJSON(
'/api/temperature-proxy/host-status',
)) as HostProxyStatusResponse;
setHostProxyStatus(status);
if (notify) {
showSuccess('Host proxy status refreshed', 2000);
}
} catch (err) {
logger.error('Failed to refresh host proxy status', err);
showError('Failed to refresh host proxy status');
}
};
createEffect(() => {
if (typeof window === 'undefined') {
return;
}
const shouldPoll = currentTab() === 'proxmox' || currentTab() === 'diagnostics';
if (!shouldPoll) {
return;
}
void runDiagnostics();
void refreshHostProxyStatus(false);
const intervalId = window.setInterval(() => {
void runDiagnostics();
void refreshHostProxyStatus(false);
}, 60000);
onCleanup(() => {
window.clearInterval(intervalId);
});
});
const handleRegisterProxyNodes = async () => { const handleRegisterProxyNodes = async () => {
if (proxyActionLoading()) return; if (proxyActionLoading()) return;
setProxyActionLoading('register-nodes'); setProxyActionLoading('register-nodes');
@ -1548,7 +1668,11 @@ const Settings: Component<SettingsProps> = (props) => {
} }
} catch (error) { } catch (error) {
logger.error('Failed to update temperature monitoring setting', error); logger.error('Failed to update temperature monitoring setting', error);
notificationStore.error('Failed to update temperature monitoring setting'); notificationStore.error(
error instanceof Error
? error.message
: 'Failed to update temperature monitoring setting',
);
setTemperatureMonitoringEnabled(previous); setTemperatureMonitoringEnabled(previous);
} finally { } finally {
setSavingTemperatureSetting(false); setSavingTemperatureSetting(false);
@ -1589,7 +1713,11 @@ const Settings: Component<SettingsProps> = (props) => {
} }
} catch (error) { } catch (error) {
logger.error('Failed to update node temperature monitoring setting', error); logger.error('Failed to update node temperature monitoring setting', error);
notificationStore.error('Failed to update temperature monitoring setting'); notificationStore.error(
error instanceof Error
? error.message
: 'Failed to update temperature monitoring setting',
);
// Revert on error // Revert on error
setNodes( setNodes(
nodes().map((n) => (n.id === nodeId ? { ...n, temperatureMonitoringEnabled: previous } : n)), nodes().map((n) => (n.id === nodeId ? { ...n, temperatureMonitoringEnabled: previous } : n)),
@ -5318,11 +5446,11 @@ const Settings: Component<SettingsProps> = (props) => {
</For> </For>
</div> </div>
</Show> </Show>
<Show <Show
when={ when={
temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0 temp().httpProxies && (temp().httpProxies as TemperatureProxyHTTPStatus[]).length > 0
} }
> >
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2"> <div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="font-semibold text-gray-700 dark:text-gray-200"> <div class="font-semibold text-gray-700 dark:text-gray-200">
HTTPS proxies HTTPS proxies
@ -5358,7 +5486,61 @@ const Settings: Component<SettingsProps> = (props) => {
)} )}
</For> </For>
</div> </div>
<div class="mt-2 text-[0.65rem] text-blue-700 dark:text-blue-300">
Learn more:{" "}
<a
href="https://github.com/rcourtman/Pulse/blob/main/docs/TEMPERATURE_MONITORING.md"
target="_blank"
rel="noreferrer"
class="underline hover:text-blue-500"
>
Temperature Monitoring docs
</a>
</div>
</Show> </Show>
<Show
when={
temp().socketHostCooldowns &&
(temp().socketHostCooldowns as TemperatureProxySocketHost[]).length > 0
}
>
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="font-semibold text-gray-700 dark:text-gray-200">
Socket cooldowns
</div>
<For each={temp().socketHostCooldowns || []}>
{(entry) => (
<div class="rounded border border-amber-200 dark:border-amber-700 px-2 py-1.5 space-y-1">
<div class="flex items-center justify-between">
<div>
<div class="font-medium text-gray-700 dark:text-gray-200">
{entry.node || entry.host || 'Host'}
</div>
<Show when={entry.cooldownUntil}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Until {entry.cooldownUntil}
</div>
</Show>
</div>
<span class="px-2 py-0.5 rounded text-white text-xs bg-amber-500">
Cooling
</span>
</div>
<Show when={typeof entry.secondsRemaining === 'number'}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Retrying in ~{formatUptime(entry.secondsRemaining || 0)}
</div>
</Show>
<Show when={entry.lastError}>
<div class="text-[0.65rem] text-red-500">
{entry.lastError}
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
<Show <Show
when={proxyNodeChecksSupported()} when={proxyNodeChecksSupported()}
fallback={ fallback={
@ -5386,6 +5568,68 @@ const Settings: Component<SettingsProps> = (props) => {
: 'Check proxy nodes'} : 'Check proxy nodes'}
</button> </button>
</div> </div>
<Show when={hostProxyStatus()}>
{(status) => (
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-700 dark:text-gray-200">
Pulse host proxy
</div>
<button
type="button"
class="text-xs rounded bg-blue-600 text-white px-2 py-1 hover:bg-blue-700 disabled:opacity-50"
onClick={() => void refreshHostProxyStatus(true)}
>
Refresh
</button>
</div>
<div class="grid grid-cols-2 gap-y-1">
<div>Requested</div>
<div>{status().summary?.requested ? 'Yes' : 'No'}</div>
<div>Installed</div>
<div>{status().summary?.installed ? 'Yes' : 'No'}</div>
<div>Host socket</div>
<div>{status().hostSocketPresent ? 'Present' : 'Missing'}</div>
<div>Container socket</div>
<div>{status().containerSocketPresent ? 'Present' : 'Missing'}</div>
</div>
<Show when={status().reinstallCommand}>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded bg-blue-600 text-white px-2 py-1 hover:bg-blue-700"
onClick={() => {
const command = status().reinstallCommand;
if (command) {
void copyToClipboard(command);
showSuccess('Host proxy command copied', 2000);
}
}}
>
Copy reinstall command
</button>
<Show when={status().installerURL}>
{(url) => (
<a
href={url()}
target="_blank"
rel="noreferrer"
class="rounded border border-gray-300 px-2 py-1 text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-800"
>
Download installer script
</a>
)}
</Show>
</div>
</Show>
<Show when={status().summary?.lastUpdated}>
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400">
Summary updated {status().summary?.lastUpdated}
</div>
</Show>
</div>
)}
</Show>
</Show> </Show>
<Show <Show
when={ when={
@ -6222,6 +6466,30 @@ const Settings: Component<SettingsProps> = (props) => {
return sanitizedEntry; return sanitizedEntry;
}); });
} }
if (Array.isArray(proxyDiag.socketHostCooldowns)) {
proxyDiag.socketHostCooldowns = (
proxyDiag.socketHostCooldowns as Array<Record<string, unknown>>
).map((entry) => ({
node: sanitizeHostname(
typeof entry.node === 'string' ? (entry.node as string) : undefined,
) as string,
host: sanitizeHostname(
typeof entry.host === 'string' ? (entry.host as string) : undefined,
) as string,
cooldownUntil:
typeof entry.cooldownUntil === 'string'
? (entry.cooldownUntil as string)
: undefined,
secondsRemaining:
typeof entry.secondsRemaining === 'number'
? (entry.secondsRemaining as number)
: undefined,
lastError:
typeof entry.lastError === 'string'
? sanitizeText(entry.lastError as string) ?? (entry.lastError as string)
: undefined,
}));
}
} }
if (sanitized.apiTokens && typeof sanitized.apiTokens === 'object') { if (sanitized.apiTokens && typeof sanitized.apiTokens === 'object') {

View file

@ -32,6 +32,9 @@ CURRENT_INSTALL_CTID=""
CONTAINER_CREATED_FOR_CLEANUP=false CONTAINER_CREATED_FOR_CLEANUP=false
BUILD_FROM_SOURCE_MARKER="$INSTALL_DIR/BUILD_FROM_SOURCE" BUILD_FROM_SOURCE_MARKER="$INSTALL_DIR/BUILD_FROM_SOURCE"
DETECTED_CTID="" DETECTED_CTID=""
INSTALL_SUMMARY_FILE="/etc/pulse/install_summary.json"
HOST_PROXY_REQUESTED=false
HOST_PROXY_INSTALLED=false
DEBIAN_TEMPLATE_FALLBACK="debian-12-standard_12.12-1_amd64.tar.zst" DEBIAN_TEMPLATE_FALLBACK="debian-12-standard_12.12-1_amd64.tar.zst"
DEBIAN_TEMPLATE="" DEBIAN_TEMPLATE=""
@ -1513,6 +1516,7 @@ fi'; then
case "$PROXY_MODE" in case "$PROXY_MODE" in
yes) yes)
install_proxy=true install_proxy=true
HOST_PROXY_REQUESTED=true
;; ;;
no) no)
install_proxy=false install_proxy=false
@ -1521,12 +1525,14 @@ fi'; then
# Auto-detect: install if Docker is present # Auto-detect: install if Docker is present
if [[ "$docker_in_container" == "true" ]]; then if [[ "$docker_in_container" == "true" ]]; then
install_proxy=true install_proxy=true
HOST_PROXY_REQUESTED=true
fi fi
;; ;;
*) *)
# Empty/unset - reuse earlier user choice (defaults handled already) # Empty/unset - reuse earlier user choice (defaults handled already)
if [[ "$PROXY_USER_CHOICE" == "yes" ]]; then if [[ "$PROXY_USER_CHOICE" == "yes" ]]; then
install_proxy=true install_proxy=true
HOST_PROXY_REQUESTED=true
fi fi
;; ;;
esac esac
@ -1676,6 +1682,7 @@ fi'; then
fi fi
print_success "Temperature proxy is healthy and ready" print_success "Temperature proxy is healthy and ready"
HOST_PROXY_INSTALLED=true
fi # End of health checks fi # End of health checks
# Clean up temporary binary if it was copied # Clean up temporary binary if it was copied
@ -2879,6 +2886,43 @@ create_marker_file() {
touch ~/.pulse 2>/dev/null || true touch ~/.pulse 2>/dev/null || true
} }
write_install_summary() {
local summary_dir="/etc/pulse"
mkdir -p "$summary_dir"
local host_socket="false"
if [[ -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock ]]; then
host_socket="true"
fi
local container_socket="null"
if [[ -n "${CTID:-}" ]] && command -v pct >/dev/null 2>&1; then
if pct exec "$CTID" -- test -S /mnt/pulse-proxy/pulse-sensor-proxy.sock >/dev/null 2>&1; then
container_socket="true"
else
container_socket="false"
fi
fi
local timestamp=""
if command -v date >/dev/null 2>&1; then
timestamp=$(date -Is 2>/dev/null || date)
fi
cat > "$INSTALL_SUMMARY_FILE" <<EOF
{
"generatedAt": "$timestamp",
"ctid": "${CTID:-}",
"proxy": {
"requested": ${HOST_PROXY_REQUESTED},
"installed": ${HOST_PROXY_INSTALLED},
"hostSocketPresent": $host_socket,
"containerSocketPresent": $container_socket
}
}
EOF
}
print_completion() { print_completion() {
local IP=$(hostname -I | awk '{print $1}') local IP=$(hostname -I | awk '{print $1}')
@ -2907,6 +2951,15 @@ print_completion() {
echo " Reset: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --reset" echo " Reset: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --reset"
echo " Uninstall: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --uninstall" echo " Uninstall: curl -sSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | bash -s -- --uninstall"
local proxy_status="Not installed"
if [[ -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock ]]; then
proxy_status="Installed (host socket present)"
elif [[ "$HOST_PROXY_REQUESTED" == true ]]; then
proxy_status="Install requested (pending)"
fi
echo
echo -e "${YELLOW}Temperature proxy:${NC} ${proxy_status}"
if [[ "$IN_CONTAINER" == "true" ]]; then if [[ "$IN_CONTAINER" == "true" ]]; then
local proxy_ctid="${DETECTED_CTID:-<your-container-id>}" local proxy_ctid="${DETECTED_CTID:-<your-container-id>}"
echo echo
@ -2949,6 +3002,8 @@ print_completion() {
fi fi
fi fi
write_install_summary
echo echo
} }

View file

@ -455,6 +455,19 @@ func determineTemperatureTransport(enabled bool, proxyURL, proxyToken string, so
return temperatureTransportSSHFallback return temperatureTransportSSHFallback
} }
func ensureTemperatureTransportAvailable(enabled bool, proxyURL, proxyToken string, socketAvailable bool, containerSSHBlocked bool) error {
if !enabled {
return nil
}
transport := determineTemperatureTransport(true, proxyURL, proxyToken, socketAvailable, containerSSHBlocked)
if transport == temperatureTransportSSHBlocked {
return fmt.Errorf("pulse is running in a container without access to pulse-sensor-proxy. Install the host proxy or register an HTTPS-mode sensor proxy for this node before enabling temperature monitoring")
}
return nil
}
func isContainerSSHRestricted() bool { func isContainerSSHRestricted() bool {
isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer() isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer()
if !isContainer { if !isContainer {
@ -1186,8 +1199,18 @@ func (h *ConfigHandlers) HandleAddNode(w http.ResponseWriter, r *http.Request) {
} }
} }
socketAvailable := h.monitor != nil && h.monitor.HasSocketTemperatureProxy()
containerSSHBlocked := isContainerSSHRestricted()
// Add to appropriate list // Add to appropriate list
if req.Type == "pve" { if req.Type == "pve" {
if req.TemperatureMonitoringEnabled != nil && *req.TemperatureMonitoringEnabled {
if err := ensureTemperatureTransportAvailable(true, "", "", socketAvailable, containerSSHBlocked); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if req.Password != "" && req.TokenName == "" && req.TokenValue == "" { if req.Password != "" && req.TokenName == "" && req.TokenValue == "" {
req.User = normalizePVEUser(req.User) req.User = normalizePVEUser(req.User)
} }
@ -1844,6 +1867,9 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
Interface("temperatureMonitoringEnabled", req.TemperatureMonitoringEnabled). Interface("temperatureMonitoringEnabled", req.TemperatureMonitoringEnabled).
Msg("Received node update request") Msg("Received node update request")
socketAvailable := h.monitor != nil && h.monitor.HasSocketTemperatureProxy()
containerSSHBlocked := isContainerSSHRestricted()
// Parse node ID // Parse node ID
parts := strings.Split(nodeID, "-") parts := strings.Split(nodeID, "-")
if len(parts) != 2 { if len(parts) != 2 {
@ -1862,6 +1888,13 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request
if nodeType == "pve" && index < len(h.config.PVEInstances) { if nodeType == "pve" && index < len(h.config.PVEInstances) {
pve := &h.config.PVEInstances[index] pve := &h.config.PVEInstances[index]
if req.TemperatureMonitoringEnabled != nil && *req.TemperatureMonitoringEnabled {
if err := ensureTemperatureTransportAvailable(true, pve.TemperatureProxyURL, pve.TemperatureProxyToken, socketAvailable, containerSSHBlocked); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
// Only update name if provided // Only update name if provided
if req.Name != "" { if req.Name != "" {
pve.Name = req.Name pve.Name = req.Name
@ -3991,6 +4024,14 @@ echo "Temperature Monitoring Setup (Optional)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "" echo ""
if [ "$PULSE_IS_CONTAINERIZED" = true ] && [ "$SKIP_TEMPERATURE_PROMPT" != true ]; then
if [ "${SUMMARY_PROXY_INSTALLED:-false}" != "true" ]; then
echo " During the initial Pulse installation the host-side proxy was not installed."
echo " Enabling it now lets the Pulse container read sensors securely via the host."
echo ""
fi
fi
# SSH public keys embedded from Pulse server # SSH public keys embedded from Pulse server
# Proxy key: used for ProxyJump (unrestricted but limited to port forwarding) # Proxy key: used for ProxyJump (unrestricted but limited to port forwarding)
# Sensors key: used for temperature collection (restricted to sensors -j command) # Sensors key: used for temperature collection (restricted to sensors -j command)
@ -4003,6 +4044,12 @@ TEMP_MONITORING_AVAILABLE=true
MIN_PROXY_VERSION="%s" MIN_PROXY_VERSION="%s"
PULSE_VERSION_ENDPOINT="%s/api/version" PULSE_VERSION_ENDPOINT="%s/api/version"
STANDALONE_PROXY_DEPLOYED=false STANDALONE_PROXY_DEPLOYED=false
SKIP_TEMPERATURE_PROMPT=false
if [ -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock ]; then
TEMPERATURE_ENABLED=true
SKIP_TEMPERATURE_PROMPT=true
fi
version_ge() { version_ge() {
if command -v dpkg >/dev/null 2>&1; then if command -v dpkg >/dev/null 2>&1; then
@ -4064,7 +4111,47 @@ fi
# Determine if this node is standalone (not joined to a cluster) # Determine if this node is standalone (not joined to a cluster)
IS_STANDALONE_NODE=false IS_STANDALONE_NODE=false
if ! command -v pvecm >/dev/null 2>&1 || ! pvecm status >/dev/null 2>&1; then if ! command -v pvecm >/dev/null 2>&1 || ! pvecm status >/dev/null 2>&1; then
IS_STANDALONE_NODE=true IS_STANDALONE_NODE=true
fi
INSTALL_SUMMARY_FILE="/etc/pulse/install_summary.json"
SUMMARY_PROXY_REQUESTED="false"
SUMMARY_PROXY_INSTALLED="false"
SUMMARY_PROXY_SOCKET="false"
if [ -f "$INSTALL_SUMMARY_FILE" ]; then
if command -v python3 >/dev/null 2>&1; then
if SUMMARY_EVAL=$(python3 <<'PY'
import json
from pathlib import Path
path = Path("/etc/pulse/install_summary.json")
try:
data = json.loads(path.read_text())
except Exception:
raise SystemExit(1)
proxy = data.get("proxy") or {}
def emit(key, value):
print(f"{key}={'true' if value else 'false'}")
emit("SUMMARY_PROXY_REQUESTED", proxy.get("requested"))
emit("SUMMARY_PROXY_INSTALLED", proxy.get("installed"))
emit("SUMMARY_PROXY_SOCKET", proxy.get("hostSocketPresent"))
PY
); then
eval "$SUMMARY_EVAL"
fi
elif command -v jq >/dev/null 2>&1; then
if SUMMARY_EVAL=$(jq -r '
[
"\(.proxy.requested // false)",
"\(.proxy.installed // false)",
"\(.proxy.hostSocketPresent // false)"
] | @tsv
' "$INSTALL_SUMMARY_FILE" 2>/dev/null); then
read -r requested installed host_socket <<<"$SUMMARY_EVAL"
SUMMARY_PROXY_REQUESTED=$requested
SUMMARY_PROXY_INSTALLED=$installed
SUMMARY_PROXY_SOCKET=$host_socket
fi
fi
fi fi
# Track whether temperature monitoring can work (may be disabled by checks above) # Track whether temperature monitoring can work (may be disabled by checks above)
@ -4095,8 +4182,8 @@ if [ "$PULSE_IS_CONTAINERIZED" = true ]; then
fi fi
fi fi
# If Pulse is containerized, try to install proxy automatically # If Pulse is containerized, try to install proxy automatically (unless already present)
if [ "$TEMP_MONITORING_AVAILABLE" = true ] && [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ]; then if [ "$TEMP_MONITORING_AVAILABLE" = true ] && [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ] && [ "$SKIP_TEMPERATURE_PROMPT" != true ]; then
# Try automatic installation - proxy keeps SSH credentials on the host for security # Try automatic installation - proxy keeps SSH credentials on the host for security
if true; then if true; then
# Download installer script from Pulse server # Download installer script from Pulse server
@ -4154,6 +4241,10 @@ if [ "$TEMP_MONITORING_AVAILABLE" = true ] && [ "$PULSE_IS_CONTAINERIZED" = true
fi fi
# Note: Mount configuration and container restart are handled by the installer # Note: Mount configuration and container restart are handled by the installer
if [ "$TEMP_MONITORING_AVAILABLE" = true ]; then
TEMPERATURE_ENABLED=true
SKIP_TEMPERATURE_PROMPT=true
fi
else else
echo "" echo ""
echo "⚠️ Proxy installation had issues - you may need to configure manually" echo "⚠️ Proxy installation had issues - you may need to configure manually"
@ -4196,7 +4287,11 @@ if [ -n "$SSH_PUBLIC_KEY" ] && [ -f /root/.ssh/authorized_keys ]; then
fi fi
# Single temperature monitoring prompt # Single temperature monitoring prompt
if [ "$SSH_ALREADY_CONFIGURED" = true ]; then if [ "$SKIP_TEMPERATURE_PROMPT" = true ]; then
echo "Temperature monitoring is already configured via pulse-sensor-proxy on this host."
echo "Pulse will collect temperatures as soon as you finish the setup wizard."
echo ""
elif [ "$SSH_ALREADY_CONFIGURED" = true ]; then
TEMPERATURE_ENABLED=true TEMPERATURE_ENABLED=true
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Temperature monitoring is currently ENABLED" echo "Temperature monitoring is currently ENABLED"

View file

@ -56,3 +56,28 @@ func TestDetermineTemperatureTransport(t *testing.T) {
}) })
} }
} }
func TestEnsureTemperatureTransportAvailable(t *testing.T) {
t.Parallel()
t.Run("allows socket transport", func(t *testing.T) {
t.Parallel()
if err := ensureTemperatureTransportAvailable(true, "", "", true, true); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("blocks container without proxy", func(t *testing.T) {
t.Parallel()
if err := ensureTemperatureTransportAvailable(true, "", "", false, true); err == nil {
t.Fatal("expected error when no transport is available")
}
})
t.Run("ignores disabled state", func(t *testing.T) {
t.Parallel()
if err := ensureTemperatureTransportAvailable(false, "", "", false, true); err != nil {
t.Fatalf("expected nil error when not enabling transport, got %v", err)
}
})
}

View file

@ -0,0 +1,55 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestHandleAddNodeRejectsTempsWithoutTransport(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DOCKER", "true")
cfg := &config.Config{DataPath: tempDir, ConfigPath: tempDir}
handler := newTestConfigHandlers(t, cfg)
body := bytes.NewBufferString(`{"type":"pve","name":"node-a","host":"pve-a.local","user":"root@pam","password":"secret","temperatureMonitoringEnabled":true}`)
req := httptest.NewRequest(http.MethodPost, "/api/config/nodes", body)
rec := httptest.NewRecorder()
handler.HandleAddNode(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "proxy") {
t.Fatalf("expected proxy error, got %s", rec.Body.String())
}
}
func TestHandleUpdateNodeRejectsTempsWithoutTransport(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DOCKER", "true")
cfg := &config.Config{DataPath: tempDir, ConfigPath: tempDir}
cfg.PVEInstances = []config.PVEInstance{{
Name: "pve-a",
Host: "https://pve-a.local:8006",
}}
handler := newTestConfigHandlers(t, cfg)
body := bytes.NewBufferString(`{"temperatureMonitoringEnabled":true}`)
req := httptest.NewRequest(http.MethodPut, "/api/config/nodes/pve-0", body)
rec := httptest.NewRecorder()
handler.HandleUpdateNode(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "proxy") {
t.Fatalf("expected proxy error, got %s", rec.Body.String())
}
}

View file

@ -249,6 +249,8 @@ type TemperatureProxyDiagnostic struct {
HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"` HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"`
ControlPlaneEnabled bool `json:"controlPlaneEnabled"` ControlPlaneEnabled bool `json:"controlPlaneEnabled"`
ControlPlaneStates []TemperatureProxyControlPlaneState `json:"controlPlaneStates,omitempty"` ControlPlaneStates []TemperatureProxyControlPlaneState `json:"controlPlaneStates,omitempty"`
SocketHostCooldowns []TemperatureProxySocketHost `json:"socketHostCooldowns,omitempty"`
HostProxySummary *HostProxySummary `json:"hostProxySummary,omitempty"`
} }
type TemperatureProxyControlPlaneState struct { type TemperatureProxyControlPlaneState struct {
@ -266,6 +268,23 @@ type TemperatureProxyHTTPStatus struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type TemperatureProxySocketHost struct {
Node string `json:"node,omitempty"`
Host string `json:"host,omitempty"`
CooldownUntil string `json:"cooldownUntil,omitempty"`
SecondsRemaining int `json:"secondsRemaining,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type HostProxySummary struct {
Requested bool `json:"requested"`
Installed bool `json:"installed"`
HostSocketPresent bool `json:"hostSocketPresent"`
ContainerSocketPresent *bool `json:"containerSocketPresent,omitempty"`
LastUpdated string `json:"lastUpdated,omitempty"`
CTID string `json:"ctid,omitempty"`
}
// APITokenDiagnostic reports on the state of the multi-token authentication system. // APITokenDiagnostic reports on the state of the multi-token authentication system.
type APITokenDiagnostic struct { type APITokenDiagnostic struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
@ -413,12 +432,18 @@ func (r *Router) computeDiagnostics(ctx context.Context) DiagnosticsInfo {
MemoryMB: memStats.Alloc / 1024 / 1024, MemoryMB: memStats.Alloc / 1024 / 1024,
} }
var proxySync map[string]proxySyncState var (
proxySync map[string]proxySyncState
socketHostState []monitoring.ProxyHostDiagnostics
)
if r.temperatureProxyHandlers != nil { if r.temperatureProxyHandlers != nil {
proxySync = r.temperatureProxyHandlers.SnapshotSyncStatus() proxySync = r.temperatureProxyHandlers.SnapshotSyncStatus()
} }
if r.monitor != nil {
socketHostState = r.monitor.SocketProxyHostDiagnostics()
}
diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config, proxySync) diag.TemperatureProxy = buildTemperatureProxyDiagnostic(r.config, proxySync, socketHostState)
diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor) diag.APITokens = buildAPITokenDiagnostic(r.config, r.monitor)
// Test each configured node // Test each configured node
@ -674,7 +699,7 @@ func buildDiscoveryDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *
return discovery return discovery
} }
func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]proxySyncState) *TemperatureProxyDiagnostic { func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]proxySyncState, hostStates []monitoring.ProxyHostDiagnostics) *TemperatureProxyDiagnostic {
diag := &TemperatureProxyDiagnostic{} diag := &TemperatureProxyDiagnostic{}
appendNote := func(note string) { appendNote := func(note string) {
@ -716,6 +741,9 @@ func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]p
if !diag.SocketFound { 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).") appendNote("No proxy socket detected inside the container. Remove the affected node in Pulse, then re-add it using the installer script from Settings → Nodes to regenerate the mount (or rerun the host installer script if you prefer).")
if cfg != nil && cfg.TemperatureMonitoringEnabled {
appendNote("Global temperature monitoring is enabled but the host proxy socket is missing; reinstall the proxy or disable temperatures until it is restored.")
}
} else if diag.SocketPath == "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" { } else if diag.SocketPath == "/run/pulse-sensor-proxy/pulse-sensor-proxy.sock" {
// Only warn about /run mount in LXC containers where /mnt/pulse-proxy is preferred // Only warn about /run mount in LXC containers where /mnt/pulse-proxy is preferred
// Docker deployments correctly use /run/pulse-sensor-proxy per docker-compose.yml // Docker deployments correctly use /run/pulse-sensor-proxy per docker-compose.yml
@ -867,9 +895,108 @@ func buildTemperatureProxyDiagnostic(cfg *config.Config, syncStates map[string]p
} }
} }
if len(hostStates) > 0 && cfg != nil {
now := time.Now()
cooldowns := make([]TemperatureProxySocketHost, 0, len(hostStates))
for _, state := range hostStates {
if state.Host == "" || state.CooldownUntil.IsZero() {
continue
}
if now.After(state.CooldownUntil) {
continue
}
entry := TemperatureProxySocketHost{
Host: state.Host,
CooldownUntil: state.CooldownUntil.UTC().Format(time.RFC3339),
SecondsRemaining: int(time.Until(state.CooldownUntil).Seconds()),
LastError: state.LastError,
}
if entry.SecondsRemaining < 0 {
entry.SecondsRemaining = 0
}
if name := matchInstanceNameByHost(cfg, state.Host); name != "" {
entry.Node = name
}
cooldowns = append(cooldowns, entry)
}
if len(cooldowns) > 0 {
diag.SocketHostCooldowns = cooldowns
}
}
if summary, err := loadHostProxySummary(); err == nil {
diag.HostProxySummary = summary
}
return diag return diag
} }
func loadHostProxySummary() (*HostProxySummary, error) {
const summaryPath = "/etc/pulse/install_summary.json"
data, err := os.ReadFile(summaryPath)
if err != nil {
return nil, err
}
var raw struct {
GeneratedAt string `json:"generatedAt"`
CTID string `json:"ctid"`
Proxy struct {
Requested bool `json:"requested"`
Installed bool `json:"installed"`
HostSocketPresent bool `json:"hostSocketPresent"`
ContainerSocketPresent *bool `json:"containerSocketPresent"`
} `json:"proxy"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
summary := &HostProxySummary{
Requested: raw.Proxy.Requested,
Installed: raw.Proxy.Installed,
HostSocketPresent: raw.Proxy.HostSocketPresent,
LastUpdated: strings.TrimSpace(raw.GeneratedAt),
CTID: strings.TrimSpace(raw.CTID),
}
if raw.Proxy.ContainerSocketPresent != nil {
value := *raw.Proxy.ContainerSocketPresent
summary.ContainerSocketPresent = &value
}
return summary, nil
}
func matchInstanceNameByHost(cfg *config.Config, host string) string {
if cfg == nil {
return ""
}
needle := normalizeHostForComparison(host)
if needle == "" {
return ""
}
for _, inst := range cfg.PVEInstances {
candidate := normalizeHostForComparison(inst.Host)
if candidate != "" && strings.EqualFold(candidate, needle) {
return strings.TrimSpace(inst.Name)
}
}
return ""
}
func normalizeHostForComparison(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
trimmed = strings.TrimPrefix(trimmed, "https://")
trimmed = strings.TrimPrefix(trimmed, "http://")
if idx := strings.IndexByte(trimmed, '/'); idx != -1 {
trimmed = trimmed[:idx]
}
if idx := strings.IndexByte(trimmed, ':'); idx != -1 {
trimmed = trimmed[:idx]
}
return strings.ToLower(strings.TrimSpace(trimmed))
}
func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *APITokenDiagnostic { func buildAPITokenDiagnostic(cfg *config.Config, monitor *monitoring.Monitor) *APITokenDiagnostic {
if cfg == nil { if cfg == nil {
return nil return nil

View file

@ -186,6 +186,8 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/temperature-proxy/register", r.temperatureProxyHandlers.HandleRegister) r.mux.HandleFunc("/api/temperature-proxy/register", r.temperatureProxyHandlers.HandleRegister)
r.mux.HandleFunc("/api/temperature-proxy/authorized-nodes", r.temperatureProxyHandlers.HandleAuthorizedNodes) r.mux.HandleFunc("/api/temperature-proxy/authorized-nodes", r.temperatureProxyHandlers.HandleAuthorizedNodes)
r.mux.HandleFunc("/api/temperature-proxy/unregister", RequireAdmin(r.config, r.temperatureProxyHandlers.HandleUnregister)) r.mux.HandleFunc("/api/temperature-proxy/unregister", RequireAdmin(r.config, r.temperatureProxyHandlers.HandleUnregister))
r.mux.HandleFunc("/api/temperature-proxy/install-command", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.handleTemperatureProxyInstallCommand)))
r.mux.HandleFunc("/api/temperature-proxy/host-status", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, r.handleHostProxyStatus)))
r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck))) r.mux.HandleFunc("/api/agents/docker/commands/", RequireAuth(r.config, RequireScope(config.ScopeDockerReport, r.dockerAgentHandlers.HandleCommandAck)))
r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions))) r.mux.HandleFunc("/api/agents/docker/hosts/", RequireAdmin(r.config, RequireScope(config.ScopeDockerManage, r.dockerAgentHandlers.HandleDockerHostActions)))
r.mux.HandleFunc("/api/version", r.handleVersion) r.mux.HandleFunc("/api/version", r.handleVersion)
@ -3823,6 +3825,75 @@ func (r *Router) handleDownloadTemperatureProxyMigrationScript(w http.ResponseWr
} }
} }
func (r *Router) handleTemperatureProxyInstallCommand(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
return
}
baseURL := strings.TrimSpace(r.resolvePublicURL(req))
if baseURL == "" {
http.Error(w, "Pulse public URL is not configured", http.StatusBadRequest)
return
}
baseURL = strings.TrimRight(baseURL, "/")
node := strings.TrimSpace(req.URL.Query().Get("node"))
command := fmt.Sprintf(
"curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | sudo bash -s -- --standalone --http-mode --pulse-server %s",
baseURL,
)
response := map[string]string{
"command": command,
"pulseURL": baseURL,
}
if node != "" {
response["node"] = node
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to serialize proxy install command response")
}
}
func (r *Router) handleHostProxyStatus(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed", nil)
return
}
hostSocket := fileExists("/run/pulse-sensor-proxy/pulse-sensor-proxy.sock")
containerSocket := fileExists("/mnt/pulse-proxy/pulse-sensor-proxy.sock")
resp := map[string]interface{}{
"hostSocketPresent": hostSocket,
"containerSocketPresent": containerSocket,
"lastChecked": time.Now().UTC().Format(time.RFC3339),
}
if summary, err := loadHostProxySummary(); err == nil && summary != nil {
resp["summary"] = summary
}
baseURL := strings.TrimRight(r.resolvePublicURL(req), "/")
if baseURL == "" {
baseURL = "http://localhost:7655"
}
ctid := "<ctid>"
if summary, ok := resp["summary"].(*HostProxySummary); ok && summary != nil && summary.CTID != "" {
ctid = summary.CTID
}
resp["reinstallCommand"] = fmt.Sprintf("curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | sudo bash -s -- --ctid %s --pulse-server %s", ctid, baseURL)
resp["installerURL"] = fmt.Sprintf("%s/api/install/install-sensor-proxy.sh", baseURL)
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to serialize host proxy status response")
}
}
func (r *Router) resolvePublicURL(req *http.Request) string { func (r *Router) resolvePublicURL(req *http.Request) string {
if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" { if publicURL := strings.TrimSpace(r.config.PublicURL); publicURL != "" {
return strings.TrimRight(publicURL, "/") return strings.TrimRight(publicURL, "/")
@ -3852,6 +3923,11 @@ func (r *Router) resolvePublicURL(req *http.Request) string {
return fmt.Sprintf("%s://%s", scheme, host) return fmt.Sprintf("%s://%s", scheme, host)
} }
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func normalizeDockerAgentArch(arch string) string { func normalizeDockerAgentArch(arch string) string {
if arch == "" { if arch == "" {
return "" return ""

View file

@ -35,6 +35,7 @@ type SystemSettingsHandler struct {
EnableTemperatureMonitoring() EnableTemperatureMonitoring()
DisableTemperatureMonitoring() DisableTemperatureMonitoring()
GetNotificationManager() *notifications.NotificationManager GetNotificationManager() *notifications.NotificationManager
HasSocketTemperatureProxy() bool
} }
} }
@ -46,6 +47,7 @@ func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPers
EnableTemperatureMonitoring() EnableTemperatureMonitoring()
DisableTemperatureMonitoring() DisableTemperatureMonitoring()
GetNotificationManager() *notifications.NotificationManager GetNotificationManager() *notifications.NotificationManager
HasSocketTemperatureProxy() bool
}, reloadSystemSettingsFunc func()) *SystemSettingsHandler { }, reloadSystemSettingsFunc func()) *SystemSettingsHandler {
return &SystemSettingsHandler{ return &SystemSettingsHandler{
config: cfg, config: cfg,
@ -64,6 +66,7 @@ func (h *SystemSettingsHandler) SetMonitor(m interface {
EnableTemperatureMonitoring() EnableTemperatureMonitoring()
DisableTemperatureMonitoring() DisableTemperatureMonitoring()
GetNotificationManager() *notifications.NotificationManager GetNotificationManager() *notifications.NotificationManager
HasSocketTemperatureProxy() bool
}) { }) {
h.monitor = m h.monitor = m
} }
@ -572,6 +575,34 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
settings.BackupPollingEnabled = updates.BackupPollingEnabled settings.BackupPollingEnabled = updates.BackupPollingEnabled
} }
if _, ok := rawRequest["temperatureMonitoringEnabled"]; ok { if _, ok := rawRequest["temperatureMonitoringEnabled"]; ok {
if updates.TemperatureMonitoringEnabled {
socketAvailable := false
if h.monitor != nil {
socketAvailable = h.monitor.HasSocketTemperatureProxy()
}
if !socketAvailable {
missing := make([]string, 0)
if h.config != nil {
for _, inst := range h.config.PVEInstances {
if strings.TrimSpace(inst.TemperatureProxyURL) == "" || strings.TrimSpace(inst.TemperatureProxyToken) == "" {
name := strings.TrimSpace(inst.Name)
if name == "" {
name = strings.TrimSpace(inst.Host)
}
if name == "" {
name = "unnamed node"
}
missing = append(missing, name)
}
}
}
if len(missing) > 0 {
message := fmt.Sprintf("Cannot enable temperature monitoring: proxy socket is not available and the following nodes do not have HTTPS proxies configured: %s", strings.Join(missing, ", "))
http.Error(w, message, http.StatusBadRequest)
return
}
}
}
settings.TemperatureMonitoringEnabled = updates.TemperatureMonitoringEnabled settings.TemperatureMonitoringEnabled = updates.TemperatureMonitoringEnabled
tempToggleRequested = true tempToggleRequested = true
} }

View file

@ -0,0 +1,27 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestHandleUpdateSystemSettingsRejectsTempsWithoutTransport(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PULSE_DOCKER", "true")
cfg := &config.Config{DataPath: tempDir, ConfigPath: tempDir, PVEInstances: []config.PVEInstance{{Name: "pve-a"}}}
persistence := config.NewConfigPersistence(tempDir)
handler := NewSystemSettingsHandler(cfg, persistence, nil, nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", bytes.NewBufferString(`{"temperatureMonitoringEnabled":true}`))
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
}

View file

@ -0,0 +1,45 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestHandleTemperatureProxyInstallCommand(t *testing.T) {
cfg := &config.Config{PublicURL: "https://pulse.example:7655"}
router := &Router{config: cfg}
req := httptest.NewRequest(http.MethodGet, "/api/temperature-proxy/install-command?node=pve-a", nil)
rec := httptest.NewRecorder()
router.handleTemperatureProxyInstallCommand(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var resp map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["node"] != "pve-a" {
t.Fatalf("expected node pve-a, got %s", resp["node"])
}
command := resp["command"]
if command == "" {
t.Fatalf("command missing in response")
}
if !strings.Contains(command, cfg.PublicURL) {
t.Fatalf("command does not include pulse URL: %s", command)
}
if !strings.Contains(command, "--standalone --http-mode") {
t.Fatalf("command missing expected flags: %s", command)
}
}

View file

@ -3472,6 +3472,19 @@ func (m *Monitor) HasSocketTemperatureProxy() bool {
return m.tempCollector.SocketProxyAvailable() return m.tempCollector.SocketProxyAvailable()
} }
// SocketProxyHostDiagnostics exposes per-host proxy cooldown state for diagnostics.
func (m *Monitor) SocketProxyHostDiagnostics() []ProxyHostDiagnostics {
m.mu.RLock()
collector := m.tempCollector
m.mu.RUnlock()
if collector == nil {
return nil
}
return collector.ProxyHostDiagnostics()
}
// checkContainerizedTempMonitoring logs a security warning if Pulse is running // checkContainerizedTempMonitoring logs a security warning if Pulse is running
// in a container with SSH-based temperature monitoring enabled // in a container with SSH-based temperature monitoring enabled
func checkContainerizedTempMonitoring() { func checkContainerizedTempMonitoring() {
@ -3514,6 +3527,13 @@ func New(cfg *config.Config) (*Monitor, error) {
// Security warning if running in container with SSH temperature monitoring // Security warning if running in container with SSH temperature monitoring
checkContainerizedTempMonitoring() checkContainerizedTempMonitoring()
if cfg != nil && cfg.TemperatureMonitoringEnabled {
isContainer := os.Getenv("PULSE_DOCKER") == "true" || system.InContainer()
if isContainer && tempCollector != nil && !tempCollector.SocketProxyAvailable() {
log.Warn().Msg("Temperature monitoring is enabled but the container does not have access to pulse-sensor-proxy. Install the proxy on the host or disable temperatures until it is available.")
}
}
stalenessTracker := NewStalenessTracker(getPollMetrics()) stalenessTracker := NewStalenessTracker(getPollMetrics())
stalenessTracker.SetBounds(cfg.AdaptivePollingBaseInterval, cfg.AdaptivePollingMaxInterval) stalenessTracker.SetBounds(cfg.AdaptivePollingBaseInterval, cfg.AdaptivePollingMaxInterval)
taskQueue := NewTaskQueue() taskQueue := NewTaskQueue()

View file

@ -43,10 +43,25 @@ type TemperatureCollector struct {
proxyMu sync.Mutex proxyMu sync.Mutex
proxyFailures int proxyFailures int
proxyCooldownUntil time.Time proxyCooldownUntil time.Time
proxyHostStates map[string]*proxyHostState
missingKeyWarned atomic.Bool missingKeyWarned atomic.Bool
legacySSHDisabled atomic.Bool legacySSHDisabled atomic.Bool
} }
type proxyHostState struct {
failures int
cooldownUntil time.Time
lastError string
}
// ProxyHostDiagnostics describes the proxy transport state for a host.
type ProxyHostDiagnostics struct {
Host string
Failures int
CooldownUntil time.Time
LastError string
}
// NewTemperatureCollector creates a new temperature collector with default SSH port (22) // NewTemperatureCollector creates a new temperature collector with default SSH port (22)
func NewTemperatureCollector(sshUser, sshKeyPath string) *TemperatureCollector { func NewTemperatureCollector(sshUser, sshKeyPath string) *TemperatureCollector {
return NewTemperatureCollectorWithPort(sshUser, sshKeyPath, 22) return NewTemperatureCollectorWithPort(sshUser, sshKeyPath, 22)
@ -59,9 +74,10 @@ func NewTemperatureCollectorWithPort(sshUser, sshKeyPath string, sshPort int) *T
} }
tc := &TemperatureCollector{ tc := &TemperatureCollector{
sshUser: sshUser, sshUser: sshUser,
sshKeyPath: sshKeyPath, sshKeyPath: sshKeyPath,
sshPort: sshPort, sshPort: sshPort,
proxyHostStates: make(map[string]*proxyHostState),
} }
homeDir := os.Getenv("HOME") homeDir := os.Getenv("HOME")
@ -126,9 +142,17 @@ func (tc *TemperatureCollector) CollectTemperatureWithProxy(ctx context.Context,
// Use Unix socket proxy if available (local deployment) // Use Unix socket proxy if available (local deployment)
if tc.isProxyEnabled() { if tc.isProxyEnabled() {
if tc.shouldSkipProxyHost(host) {
log.Debug().
Str("node", nodeName).
Str("host", host).
Msg("Skipping temperature proxy request while host is in cooldown")
return &models.Temperature{Available: false}, nil
}
output, err = tc.proxyClient.GetTemperature(host) output, err = tc.proxyClient.GetTemperature(host)
if err != nil { if err != nil {
tc.handleProxyFailure(err) tc.handleProxyFailure(host, err)
log.Debug(). log.Debug().
Str("node", nodeName). Str("node", nodeName).
Str("host", host). Str("host", host).
@ -137,6 +161,7 @@ func (tc *TemperatureCollector) CollectTemperatureWithProxy(ctx context.Context,
return &models.Temperature{Available: false}, nil return &models.Temperature{Available: false}, nil
} }
tc.handleProxySuccess() tc.handleProxySuccess()
tc.handleProxyHostSuccess(host)
} else { } else {
// SECURITY: Block SSH fallback when running in containers (unless dev mode) // SECURITY: Block SSH fallback when running in containers (unless dev mode)
// Container compromise = SSH key compromise = root access to infrastructure // Container compromise = SSH key compromise = root access to infrastructure
@ -817,6 +842,33 @@ func (tc *TemperatureCollector) isProxyEnabled() bool {
return useProxy return useProxy
} }
func (tc *TemperatureCollector) shouldSkipProxyHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
tc.proxyMu.Lock()
defer tc.proxyMu.Unlock()
state, ok := tc.proxyHostStates[host]
if !ok || state == nil {
return false
}
now := time.Now()
if state.cooldownUntil.IsZero() || now.After(state.cooldownUntil) {
// Cooldown expired; reset state so we can retry this host.
state.cooldownUntil = time.Time{}
state.failures = 0
if state.cooldownUntil.IsZero() && state.failures == 0 {
delete(tc.proxyHostStates, host)
}
return false
}
return true
}
// SocketProxyAvailable reports whether the unix socket proxy can currently be used. // SocketProxyAvailable reports whether the unix socket proxy can currently be used.
func (tc *TemperatureCollector) SocketProxyAvailable() bool { func (tc *TemperatureCollector) SocketProxyAvailable() bool {
return tc != nil && tc.isProxyEnabled() return tc != nil && tc.isProxyEnabled()
@ -831,26 +883,71 @@ func (tc *TemperatureCollector) handleProxySuccess() {
tc.proxyMu.Unlock() tc.proxyMu.Unlock()
} }
func (tc *TemperatureCollector) handleProxyFailure(err error) { func (tc *TemperatureCollector) handleProxyHostSuccess(host string) {
if tc.proxyClient == nil || !tc.shouldDisableProxy(err) { host = strings.TrimSpace(host)
if host == "" {
return
}
tc.proxyMu.Lock()
delete(tc.proxyHostStates, host)
tc.proxyMu.Unlock()
}
func (tc *TemperatureCollector) handleProxyFailure(host string, err error) {
if tc.proxyClient == nil {
return
}
if tc.shouldDisableProxy(err) {
tc.proxyMu.Lock()
tc.proxyFailures++
disable := tc.proxyFailures >= proxyFailureThreshold && tc.useProxy
if disable {
tc.useProxy = false
tc.proxyCooldownUntil = time.Now().Add(proxyRetryInterval)
tc.proxyFailures = 0
}
tc.proxyMu.Unlock()
if disable {
log.Warn().
Err(err).
Dur("cooldown", proxyRetryInterval).
Msg("Temperature proxy disabled after repeated failures; will retry later")
}
return
}
tc.handleProxyHostFailure(host, err)
}
func (tc *TemperatureCollector) handleProxyHostFailure(host string, err error) {
host = strings.TrimSpace(host)
if host == "" {
return return
} }
tc.proxyMu.Lock() tc.proxyMu.Lock()
tc.proxyFailures++ state, ok := tc.proxyHostStates[host]
disable := tc.proxyFailures >= proxyFailureThreshold && tc.useProxy if !ok || state == nil {
if disable { state = &proxyHostState{}
tc.useProxy = false tc.proxyHostStates[host] = state
tc.proxyCooldownUntil = time.Now().Add(proxyRetryInterval) }
tc.proxyFailures = 0 state.failures++
state.lastError = strings.TrimSpace(err.Error())
trip := state.failures >= proxyFailureThreshold
if trip {
state.failures = 0
state.cooldownUntil = time.Now().Add(proxyRetryInterval)
} }
tc.proxyMu.Unlock() tc.proxyMu.Unlock()
if disable { if trip {
log.Warn(). log.Warn().
Err(err). Err(err).
Str("host", host).
Dur("cooldown", proxyRetryInterval). Dur("cooldown", proxyRetryInterval).
Msg("Temperature proxy disabled after repeated failures; will retry later") Msg("Temperature proxy host in cooldown after repeated failures")
} }
} }
@ -866,3 +963,31 @@ func (tc *TemperatureCollector) shouldDisableProxy(err error) bool {
} }
return true return true
} }
// ProxyHostDiagnostics returns a snapshot of per-host proxy error state.
func (tc *TemperatureCollector) ProxyHostDiagnostics() []ProxyHostDiagnostics {
if tc == nil {
return nil
}
tc.proxyMu.Lock()
defer tc.proxyMu.Unlock()
if len(tc.proxyHostStates) == 0 {
return nil
}
result := make([]ProxyHostDiagnostics, 0, len(tc.proxyHostStates))
for host, state := range tc.proxyHostStates {
if state == nil {
continue
}
result = append(result, ProxyHostDiagnostics{
Host: host,
Failures: state.failures,
CooldownUntil: state.cooldownUntil,
LastError: state.lastError,
})
}
return result
}

View file

@ -34,6 +34,7 @@ const (
ErrorTypeSSH // SSH connectivity issues ErrorTypeSSH // SSH connectivity issues
ErrorTypeSensor // Sensor command failures ErrorTypeSensor // Sensor command failures
ErrorTypeTimeout // Operation timeout ErrorTypeTimeout // Operation timeout
ErrorTypeNode // Node allowlist or validation failures
) )
// ProxyError wraps errors with classification // ProxyError wraps errors with classification
@ -125,6 +126,16 @@ func classifyError(err error, respError string) *ProxyError {
// Check response error messages first (even if err is nil) // Check response error messages first (even if err is nil)
// This handles cases where the socket succeeds but the proxy returns an application error // This handles cases where the socket succeeds but the proxy returns an application error
if respError != "" { if respError != "" {
// Node validator/allowlist rejections should not disable the proxy globally
if contains(respError, "rejected by validator", "not in allowlist", "node \"") {
return &ProxyError{
Type: ErrorTypeNode,
Message: respError,
Retryable: false,
Wrapped: fmt.Errorf("%s", respError),
}
}
// Rate limiting - never retry // Rate limiting - never retry
if contains(respError, "rate limit") { if contains(respError, "rate limit") {
return &ProxyError{ return &ProxyError{
@ -412,7 +423,15 @@ func (c *Client) GetTemperature(nodeHost string) (string, error) {
} }
if !resp.Success { if !resp.Success {
return "", fmt.Errorf("proxy error: %s", resp.Error) if proxyErr := classifyError(nil, resp.Error); proxyErr != nil {
return "", proxyErr
}
return "", &ProxyError{
Type: ErrorTypeUnknown,
Message: resp.Error,
Retryable: false,
Wrapped: fmt.Errorf("%s", resp.Error),
}
} }
// Extract temperature JSON string // Extract temperature JSON string