mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Improve temperature proxy diagnostics and tests
This commit is contained in:
parent
e178ae50a5
commit
61f011af1d
9 changed files with 600 additions and 21 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -103,6 +103,7 @@ screenshots/
|
|||
.devdata/
|
||||
test-*.js
|
||||
test-*.sh
|
||||
!scripts/tests/test-sensor-proxy-http.sh
|
||||
test-*.html
|
||||
*.backup.*
|
||||
.env.dev
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@ func (h *HTTPServer) Start() error {
|
|||
MinVersion: tls.VersionTLS12,
|
||||
CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256},
|
||||
PreferServerCipherSuites: true,
|
||||
// Force HTTP/1.1 because the Pulse backend HTTP client currently expects classic TLS/HTTP semantics.
|
||||
// HTTP/2 responses from the proxy caused intermittent hangs/timeouts in the backend client,
|
||||
// so we explicitly disable ALPN advertising h2 for now.
|
||||
NextProtos: []string{"http/1.1"},
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
|
|
@ -67,6 +71,8 @@ func (h *HTTPServer) Start() error {
|
|||
WriteTimeout: h.config.WriteTimeout,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20, // 1 MB
|
||||
// Disable HTTP/2 upgrade paths until the backend client stack is hardened for it.
|
||||
TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
|
||||
}
|
||||
|
||||
log.Info().
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
## Testing Checklist
|
||||
|
||||
- `scripts/tests/run.sh`
|
||||
- `scripts/tests/test-sensor-proxy-http.sh` (requires Docker; validates uninstall → HTTP install cycle)
|
||||
- Relevant `scripts/tests/integration/*` scripts (add new ones if needed)
|
||||
- Manual `--dry-run` invocation of the script when feasible
|
||||
- Bundle validation: `bash -n dist/<script>.sh` and `dist/<script>.sh --dry-run`
|
||||
|
|
|
|||
|
|
@ -14,13 +14,24 @@ Pulse can display real-time CPU and NVMe temperatures directly in your dashboard
|
|||
|
||||
## Deployment-Specific Setup
|
||||
|
||||
> **Important:** Temperature monitoring setup differs by deployment type:
|
||||
> - **LXC containers:** Fully automatic via the setup script (Settings → Nodes → Setup Script)
|
||||
> - **Docker containers:** Requires manual proxy installation (see below) OR use pulse-host-agent
|
||||
> - **Docker in VM:** Use pulse-host-agent on the Proxmox host (see [Docker in VM Setup](#docker-in-vm-setup))
|
||||
> - **Native installs:** Direct SSH, no proxy needed
|
||||
> **Important:** Pick the transport that matches your deployment:
|
||||
> - **Pulse running inside a container (Docker/LXC):** Use the Unix-socket path (bind mount `/run/pulse-sensor-proxy`) so SSH keys never leave the host. Details in [Quick Start for Docker Deployments](#quick-start-for-docker-deployments).
|
||||
> - **Pulse talking to remote Proxmox hosts / standalone nodes:** Install `pulse-sensor-proxy` directly on each host with `--standalone --http-mode` so Pulse reaches it over HTTPS 8443. See [HTTP Mode for Remote Hosts](#http-mode-for-remote-hosts).
|
||||
> - **Docker-in-VM / “Pulse can’t see the host sensors”:** Use `pulse-host-agent`.
|
||||
> - **Native installs:** Direct SSH works, but the proxy/host-agent options are preferred for key isolation.
|
||||
>
|
||||
> **For automation (Ansible/Terraform/etc.):** Jump to [Automation-Friendly Installation](#automation-friendly-installation)
|
||||
> **Automation users:** both installers (`install-sensor-proxy.sh` and `install-host-agent.sh`) accept non-interactive flags; jump to [Automation-Friendly Installation](#automation-friendly-installation) for samples.
|
||||
|
||||
### Transport decision matrix
|
||||
|
||||
| Pulse Deployment | Recommended Transport | Why |
|
||||
|------------------|----------------------|-----|
|
||||
| Pulse in Docker/LXC on the Proxmox host | Unix socket via `/run/pulse-sensor-proxy` bind mount | Keeps SSH keys on the host, enforces SO\_PEERCRED auth, no network exposure |
|
||||
| Pulse in Docker inside a VM | `pulse-host-agent` on the Proxmox host | VM can’t mount the host socket, host agent reports over HTTPS instead |
|
||||
| Pulse (any host) monitoring additional Proxmox nodes on the LAN | `install-sensor-proxy.sh --standalone --http-mode` on each node | Lets each node host its own proxy so Pulse reaches it over HTTPS 8443 |
|
||||
| Bare-metal Pulse install on the same Proxmox host | Either socket or HTTP works; socket is simpler | You already have direct filesystem access; the installer auto-configures the socket |
|
||||
|
||||
Use the socket path wherever Pulse is containerised. Use HTTP mode when the sensors live on machines Pulse cannot mount directly.
|
||||
|
||||
## Docker in VM Setup
|
||||
|
||||
|
|
@ -123,6 +134,33 @@ Open Pulse in your browser and check the node dashboard. CPU and drive temperatu
|
|||
|
||||
**Having issues?** See [Troubleshooting](#troubleshooting) below.
|
||||
|
||||
## HTTP Mode for Remote Hosts
|
||||
|
||||
When Pulse cannot share the `/run/pulse-sensor-proxy` socket (for example, you run Pulse on one host but want temperatures from other Proxmox nodes), install the proxy directly on each target host and expose it over HTTPS 8443.
|
||||
|
||||
1. **Install the proxy in HTTP mode** on each Proxmox node:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/scripts/install-sensor-proxy.sh | \
|
||||
sudo bash -s -- --standalone --http-mode --pulse-server http://192.168.0.123:7655
|
||||
```
|
||||
Replace the Pulse URL with the server that should receive temperatures. The installer:
|
||||
- Generates a TLS certificate (`/etc/pulse-sensor-proxy/tls/`)
|
||||
- Registers with Pulse and writes the proxy URL/token into `nodes.enc`
|
||||
- Starts `pulse-sensor-proxy.service` listening on `https://<node>:8443`
|
||||
|
||||
2. **Allow Pulse to reach port 8443** (host firewall, VLAN ACLs, etc.). Only Pulse needs access; the installer’s service file restricts the listener to HTTPS with bearer auth.
|
||||
|
||||
3. **Verify the endpoint manually** (optional but recommended):
|
||||
```bash
|
||||
TOKEN=$(sudo cat /etc/pulse-sensor-proxy/.http-auth-token)
|
||||
curl -k -H "Authorization: Bearer ${TOKEN}" "https://node.example:8443/temps?node=shortname"
|
||||
```
|
||||
You should receive JSON with the `sensors` payload.
|
||||
|
||||
4. **Restart Pulse** (or wait for config reload) so it notices the new proxy URL/token. Pulse will automatically try HTTP first for nodes with `TemperatureProxyURL` configured, then fall back to the Unix socket (if mounted) and finally SSH.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Disable Temperature Monitoring
|
||||
|
|
|
|||
|
|
@ -7,10 +7,16 @@ type NodeConfigWithStatus = NodeConfig & {
|
|||
status: 'connected' | 'disconnected' | 'offline' | 'error' | 'pending';
|
||||
};
|
||||
|
||||
export interface TemperatureTransportInfo {
|
||||
httpMap: Record<string, { reachable: boolean; error?: string; url?: string }>;
|
||||
socketStatus: 'healthy' | 'error' | 'missing';
|
||||
}
|
||||
|
||||
interface PveNodesTableProps {
|
||||
nodes: NodeConfigWithStatus[];
|
||||
stateNodes: { instance: string; status?: string; connectionHealth?: string }[];
|
||||
globalTemperatureMonitoringEnabled?: boolean;
|
||||
temperatureTransports?: TemperatureTransportInfo | null;
|
||||
onTestConnection: (nodeId: string) => void;
|
||||
onEdit: (node: NodeConfigWithStatus) => void;
|
||||
onDelete: (node: NodeConfigWithStatus) => void;
|
||||
|
|
@ -18,6 +24,12 @@ interface PveNodesTableProps {
|
|||
|
||||
type StatusMeta = { dotClass: string; label: string; labelClass: string };
|
||||
|
||||
type TemperatureTransportBadge = {
|
||||
label: string;
|
||||
badgeClass: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const STATUS_META: Record<string, StatusMeta> = {
|
||||
online: {
|
||||
dotClass: 'bg-green-500',
|
||||
|
|
@ -46,6 +58,58 @@ const STATUS_META: Record<string, StatusMeta> = {
|
|||
},
|
||||
};
|
||||
|
||||
const resolveTemperatureTransport = (
|
||||
node: NodeConfigWithStatus,
|
||||
info: TemperatureTransportInfo | null | undefined,
|
||||
globalEnabled: boolean,
|
||||
): TemperatureTransportBadge => {
|
||||
const monitoringEnabled = isTemperatureMonitoringEnabled(node, globalEnabled);
|
||||
if (!monitoringEnabled) {
|
||||
return {
|
||||
label: 'Temp disabled',
|
||||
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300',
|
||||
};
|
||||
}
|
||||
|
||||
const key = (node.name || '').toLowerCase();
|
||||
const httpEntry = info?.httpMap?.[key];
|
||||
if (httpEntry) {
|
||||
if (httpEntry.reachable) {
|
||||
return {
|
||||
label: 'HTTPS proxy',
|
||||
badgeClass: 'bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300',
|
||||
description: httpEntry.url,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: 'HTTPS error',
|
||||
badgeClass: 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300',
|
||||
description: httpEntry.error || 'Proxy unreachable',
|
||||
};
|
||||
}
|
||||
|
||||
if (info) {
|
||||
if (info.socketStatus === 'healthy') {
|
||||
return {
|
||||
label: 'Socket proxy',
|
||||
badgeClass: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
|
||||
};
|
||||
}
|
||||
if (info.socketStatus === 'error') {
|
||||
return {
|
||||
label: 'Socket error',
|
||||
badgeClass: 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-300',
|
||||
description: 'Proxy socket not responding',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'SSH fallback',
|
||||
badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300',
|
||||
};
|
||||
};
|
||||
|
||||
const isTemperatureMonitoringEnabled = (
|
||||
node: NodeConfigWithStatus,
|
||||
globalEnabled: boolean,
|
||||
|
|
@ -124,6 +188,13 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
|
|||
const clusterName = createMemo(() =>
|
||||
'clusterName' in node && node.clusterName ? node.clusterName : 'Unknown',
|
||||
);
|
||||
const transportMeta = createMemo(() =>
|
||||
resolveTemperatureTransport(
|
||||
node,
|
||||
props.temperatureTransports,
|
||||
props.globalTemperatureMonitoringEnabled ?? true,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<tr class="even:bg-gray-50/60 dark:even:bg-gray-800/30 hover:bg-blue-50/40 dark:hover:bg-blue-900/20 transition-colors">
|
||||
<td class="align-top py-3 pl-4 pr-3">
|
||||
|
|
@ -245,7 +316,19 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
|
|||
Temperature
|
||||
</span>
|
||||
)}
|
||||
<Show when={transportMeta()}>
|
||||
<span
|
||||
class={`text-xs px-2 py-1 rounded ${transportMeta().badgeClass}`}
|
||||
>
|
||||
{transportMeta().label}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={transportMeta()?.description}>
|
||||
<div class="mt-1 text-[0.65rem] text-gray-500 dark:text-gray-400">
|
||||
{transportMeta()?.description}
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
<td class="align-top px-3 py-3 whitespace-nowrap">
|
||||
<span class={`inline-flex items-center gap-2 text-xs font-medium ${statusMeta().labelClass}`}>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,12 @@ import APITokenManager from './APITokenManager';
|
|||
import { OIDCPanel } from './OIDCPanel';
|
||||
import { QuickSecuritySetup } from './QuickSecuritySetup';
|
||||
import { SecurityPostureSummary } from './SecurityPostureSummary';
|
||||
import { PveNodesTable, PbsNodesTable, PmgNodesTable } from './ConfiguredNodeTables';
|
||||
import {
|
||||
PveNodesTable,
|
||||
PbsNodesTable,
|
||||
PmgNodesTable,
|
||||
type TemperatureTransportInfo,
|
||||
} from './ConfiguredNodeTables';
|
||||
import { SettingsSectionNav } from './SettingsSectionNav';
|
||||
import { SettingsAPI } from '@/api/settings';
|
||||
import { NodesAPI } from '@/api/nodes';
|
||||
|
|
@ -127,6 +132,13 @@ interface SystemDiagnostic {
|
|||
memoryMB: number;
|
||||
}
|
||||
|
||||
interface TemperatureProxyHTTPStatus {
|
||||
node: string;
|
||||
url?: string;
|
||||
reachable: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface TemperatureProxyDiagnostic {
|
||||
legacySSHDetected: boolean;
|
||||
recommendProxyUpgrade: boolean;
|
||||
|
|
@ -141,6 +153,7 @@ interface TemperatureProxyDiagnostic {
|
|||
proxySshDirectory?: string;
|
||||
legacySshKeyCount?: number;
|
||||
notes?: string[];
|
||||
httpProxies?: TemperatureProxyHTTPStatus[];
|
||||
}
|
||||
|
||||
interface APITokenSummary {
|
||||
|
|
@ -799,12 +812,56 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
return `${Math.floor(seconds)}s`;
|
||||
};
|
||||
|
||||
const emitTemperatureProxyWarnings = (diag: DiagnosticsData | null) => {
|
||||
if (!diag?.temperatureProxy?.httpProxies) {
|
||||
return;
|
||||
}
|
||||
const failing = (diag.temperatureProxy.httpProxies as TemperatureProxyHTTPStatus[]).filter(
|
||||
(proxy) => proxy && proxy.node && !proxy.reachable,
|
||||
);
|
||||
if (failing.length > 0) {
|
||||
const nodes = failing.map((proxy) => proxy.node || 'Unknown').join(', ');
|
||||
showWarning(`Pulse cannot reach HTTPS temperature proxy on: ${nodes}`);
|
||||
}
|
||||
};
|
||||
|
||||
const temperatureTransportInfo = createMemo<TemperatureTransportInfo | null>(() => {
|
||||
const diag = diagnosticsData();
|
||||
if (!diag?.temperatureProxy) {
|
||||
return null;
|
||||
}
|
||||
const httpMap: TemperatureTransportInfo['httpMap'] = {};
|
||||
const proxies = diag.temperatureProxy.httpProxies || [];
|
||||
proxies.forEach((proxy) => {
|
||||
if (!proxy || !proxy.node) {
|
||||
return;
|
||||
}
|
||||
const key = proxy.node.trim().toLowerCase();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
httpMap[key] = {
|
||||
reachable: Boolean(proxy.reachable),
|
||||
error: proxy.error || undefined,
|
||||
url: proxy.url || undefined,
|
||||
};
|
||||
});
|
||||
const socketStatus: TemperatureTransportInfo['socketStatus'] =
|
||||
diag.temperatureProxy.socketFound && diag.temperatureProxy.proxyReachable
|
||||
? 'healthy'
|
||||
: diag.temperatureProxy.socketFound
|
||||
? 'error'
|
||||
: 'missing';
|
||||
return { httpMap, socketStatus };
|
||||
});
|
||||
|
||||
const runDiagnostics = async () => {
|
||||
setRunningDiagnostics(true);
|
||||
try {
|
||||
const response = await apiFetch('/api/diagnostics');
|
||||
const diag = await response.json();
|
||||
setDiagnosticsData(diag);
|
||||
emitTemperatureProxyWarnings(diag);
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch diagnostics', err);
|
||||
showError('Failed to run diagnostics');
|
||||
|
|
@ -2466,6 +2523,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
nodes={pveNodes()}
|
||||
stateNodes={state.nodes ?? []}
|
||||
globalTemperatureMonitoringEnabled={temperatureMonitoringEnabled()}
|
||||
temperatureTransports={temperatureTransportInfo()}
|
||||
onTestConnection={testNodeConnection}
|
||||
onEdit={(node) => {
|
||||
setEditingNode(node);
|
||||
|
|
@ -5069,9 +5127,19 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<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="flex items-center justify-between gap-3 mb-2">
|
||||
<h5 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Temperature proxy
|
||||
</h5>
|
||||
<a
|
||||
href="https://github.com/rcourtman/Pulse/blob/main/docs/TEMPERATURE_MONITORING.md#transport-decision-matrix"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200 underline-offset-2 hover:underline"
|
||||
>
|
||||
Setup guide
|
||||
</a>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -5125,6 +5193,47 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={
|
||||
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="font-semibold text-gray-700 dark:text-gray-200">
|
||||
HTTPS proxies
|
||||
</div>
|
||||
<For each={temp().httpProxies || []}>
|
||||
{(proxy) => (
|
||||
<div class="rounded border border-gray-200 dark:border-gray-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">
|
||||
{proxy.node || 'Proxy'}
|
||||
</div>
|
||||
<Show when={proxy.url}>
|
||||
<div class="text-[0.65rem] text-gray-500 dark:text-gray-400 break-all">
|
||||
{proxy.url}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
proxy.reachable ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
{proxy.reachable ? 'Healthy' : 'Error'}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={!proxy.reachable && proxy.error}>
|
||||
<div class="text-[0.65rem] text-red-500">
|
||||
{proxy.error}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -5953,6 +6062,29 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
if (Array.isArray(proxyDiag.notes)) {
|
||||
proxyDiag.notes = sanitizeNotesArray(proxyDiag.notes);
|
||||
}
|
||||
if (Array.isArray(proxyDiag.httpProxies)) {
|
||||
proxyDiag.httpProxies = (
|
||||
proxyDiag.httpProxies as Array<Record<string, unknown>>
|
||||
).map((entry, index: number) => {
|
||||
const sanitizedEntry: TemperatureProxyHTTPStatus = {
|
||||
node: sanitizeHostname(
|
||||
typeof entry.node === 'string'
|
||||
? (entry.node as string)
|
||||
: `http-proxy-${index + 1}`,
|
||||
),
|
||||
reachable: Boolean(entry.reachable),
|
||||
};
|
||||
if (typeof entry.url === 'string') {
|
||||
sanitizedEntry.url =
|
||||
sanitizeText(entry.url as string) ?? (entry.url as string);
|
||||
}
|
||||
if (typeof entry.error === 'string') {
|
||||
sanitizedEntry.error =
|
||||
sanitizeText(entry.error as string) ?? (entry.error as string);
|
||||
}
|
||||
return sanitizedEntry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitized.apiTokens && typeof sanitized.apiTokens === 'object') {
|
||||
|
|
|
|||
|
|
@ -234,17 +234,25 @@ type SystemDiagnostic struct {
|
|||
|
||||
// TemperatureProxyDiagnostic summarizes proxy detection state
|
||||
type TemperatureProxyDiagnostic struct {
|
||||
SocketFound bool `json:"socketFound"`
|
||||
SocketPath string `json:"socketPath,omitempty"`
|
||||
SocketPermissions string `json:"socketPermissions,omitempty"`
|
||||
SocketOwner string `json:"socketOwner,omitempty"`
|
||||
SocketGroup string `json:"socketGroup,omitempty"`
|
||||
ProxyReachable bool `json:"proxyReachable"`
|
||||
ProxyVersion string `json:"proxyVersion,omitempty"`
|
||||
ProxyPublicKeySHA256 string `json:"proxyPublicKeySha256,omitempty"`
|
||||
ProxySSHDirectory string `json:"proxySshDirectory,omitempty"`
|
||||
LegacySSHKeyCount int `json:"legacySshKeyCount,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
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"`
|
||||
HTTPProxies []TemperatureProxyHTTPStatus `json:"httpProxies,omitempty"`
|
||||
}
|
||||
|
||||
type TemperatureProxyHTTPStatus struct {
|
||||
Node string `json:"node"`
|
||||
URL string `json:"url"`
|
||||
Reachable bool `json:"reachable"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// APITokenDiagnostic reports on the state of the multi-token authentication system.
|
||||
|
|
@ -741,6 +749,32 @@ func buildTemperatureProxyDiagnostic(cfg *config.Config) *TemperatureProxyDiagno
|
|||
diag.LegacySSHKeyCount = count
|
||||
appendNote(fmt.Sprintf("Found %d SSH key(s) inside the Pulse data directory. Remove them after migrating to the secure proxy.", count))
|
||||
}
|
||||
|
||||
for _, inst := range cfg.PVEInstances {
|
||||
url := strings.TrimSpace(inst.TemperatureProxyURL)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
status := TemperatureProxyHTTPStatus{
|
||||
Node: strings.TrimSpace(inst.Name),
|
||||
URL: url,
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(inst.TemperatureProxyToken)
|
||||
if token == "" {
|
||||
status.Error = "missing authentication token"
|
||||
} else {
|
||||
client := tempproxy.NewHTTPClient(url, token)
|
||||
if err := client.HealthCheck(); err != nil {
|
||||
status.Error = err.Error()
|
||||
} else {
|
||||
status.Reachable = true
|
||||
}
|
||||
}
|
||||
|
||||
diag.HTTPProxies = append(diag.HTTPProxies, status)
|
||||
}
|
||||
}
|
||||
|
||||
return diag
|
||||
|
|
|
|||
|
|
@ -177,3 +177,50 @@ func (c *HTTPClient) GetTemperature(nodeHost string) (string, error) {
|
|||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
// HealthCheck calls the proxy /health endpoint to verify connectivity.
|
||||
func (c *HTTPClient) HealthCheck() error {
|
||||
if !c.IsAvailable() {
|
||||
return &ProxyError{
|
||||
Type: ErrorTypeTransport,
|
||||
Message: "HTTP proxy not configured",
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/health", c.baseURL)
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return &ProxyError{
|
||||
Type: ErrorTypeTransport,
|
||||
Message: "failed to create HTTP request",
|
||||
Retryable: false,
|
||||
Wrapped: err,
|
||||
}
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.authToken))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ProxyError{
|
||||
Type: ErrorTypeTransport,
|
||||
Message: "HTTP request failed",
|
||||
Retryable: true,
|
||||
Wrapped: err,
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return &ProxyError{
|
||||
Type: ErrorTypeTransport,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))),
|
||||
Retryable: resp.StatusCode >= 500,
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
237
scripts/tests/test-sensor-proxy-http.sh
Executable file
237
scripts/tests/test-sensor-proxy-http.sh
Executable file
|
|
@ -0,0 +1,237 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Smoke test: install-sensor-proxy HTTP mode (uninstall → install → health check)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ARTIFACT_DIR="${ROOT_DIR}/tmp/sensor-proxy-test"
|
||||
LOCAL_BINARY="${ARTIFACT_DIR}/pulse-sensor-proxy-linux-amd64"
|
||||
DOCKER_IMAGE="${SENSOR_PROXY_TEST_IMAGE:-debian:12}"
|
||||
|
||||
log() {
|
||||
printf '[sensor-proxy-test] %s\n' "$*"
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
log "Missing required command: $1"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
[[ -n "${CONTAINER_SCRIPT:-}" && -f "${CONTAINER_SCRIPT}" ]] && rm -f "${CONTAINER_SCRIPT}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
main() {
|
||||
if ! require_cmd go; then
|
||||
log "Go toolchain is required for this test. Skipping."
|
||||
return 0
|
||||
fi
|
||||
if ! require_cmd docker; then
|
||||
log "Docker not available. Skipping sensor-proxy installer test."
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
log "Building pulse-sensor-proxy binary for test harness..."
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "${LOCAL_BINARY}" ./cmd/pulse-sensor-proxy
|
||||
)
|
||||
|
||||
CONTAINER_SCRIPT="$(mktemp -t sensor-proxy-http-XXXXXX.sh)"
|
||||
cat <<'EOS' >"${CONTAINER_SCRIPT}"
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update >/dev/null
|
||||
apt-get install -y --no-install-recommends ca-certificates curl openssl >/dev/null
|
||||
|
||||
INSTALLER="/workspace/scripts/install-sensor-proxy.sh"
|
||||
LOCAL_BIN="${SENSOR_PROXY_LOCAL_BINARY:-/artifacts/pulse-sensor-proxy-linux-amd64}"
|
||||
STUB_DIR=/tmp/sensor-proxy-stubs
|
||||
mkdir -p "${STUB_DIR}"
|
||||
|
||||
create_curl_stub() {
|
||||
cat <<'EOF' >"${STUB_DIR}/curl"
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
args="$*"
|
||||
if [[ "${args}" == *"/api/temperature-proxy/register"* ]]; then
|
||||
printf '{"success":true,"token":"INTEGRATION-TOKEN","pve_instance":"integration"}\n200\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${args}" == *"/api/health"* ]]; then
|
||||
printf '{"status":"ok"}\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec /usr/bin/curl "$@"
|
||||
EOF
|
||||
chmod +x "${STUB_DIR}/curl"
|
||||
}
|
||||
|
||||
create_systemctl_stub() {
|
||||
cat <<'EOF' >"${STUB_DIR}/systemctl"
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
PID_FILE=/run/pulse-sensor-proxy-test.pid
|
||||
LOG_FILE=/var/log/pulse/sensor-proxy/integration.log
|
||||
|
||||
cmd=""
|
||||
declare -a units=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
start|stop|restart|status|is-active|enable|disable|daemon-reload)
|
||||
cmd="$arg"
|
||||
;;
|
||||
--*) ;;
|
||||
*)
|
||||
units+=("$arg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "${cmd}" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ ${#units[@]} -eq 0 ]]; then
|
||||
units=("pulse-sensor-proxy.service")
|
||||
fi
|
||||
|
||||
is_proxy_unit() {
|
||||
local unit="$1"
|
||||
[[ "$unit" == "pulse-sensor-proxy" || "$unit" == "pulse-sensor-proxy.service" ]]
|
||||
}
|
||||
|
||||
start_proxy() {
|
||||
mkdir -p "$(dirname "${LOG_FILE}")"
|
||||
if [[ -f "${PID_FILE}" ]]; then
|
||||
local old_pid
|
||||
old_pid="$(cat "${PID_FILE}")"
|
||||
kill "${old_pid}" 2>/dev/null || true
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
/usr/local/bin/pulse-sensor-proxy --config /etc/pulse-sensor-proxy/config.yaml >>"${LOG_FILE}" 2>&1 &
|
||||
echo $! >"${PID_FILE}"
|
||||
}
|
||||
|
||||
stop_proxy() {
|
||||
if [[ -f "${PID_FILE}" ]]; then
|
||||
local pid
|
||||
pid="$(cat "${PID_FILE}")"
|
||||
kill "${pid}" 2>/dev/null || true
|
||||
rm -f "${PID_FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${cmd}" in
|
||||
start)
|
||||
for unit in "${units[@]}"; do
|
||||
if is_proxy_unit "${unit}"; then
|
||||
start_proxy
|
||||
fi
|
||||
done
|
||||
;;
|
||||
stop)
|
||||
for unit in "${units[@]}"; do
|
||||
if is_proxy_unit "${unit}"; then
|
||||
stop_proxy
|
||||
fi
|
||||
done
|
||||
;;
|
||||
restart)
|
||||
stop_proxy
|
||||
start_proxy
|
||||
;;
|
||||
status)
|
||||
if [[ -f "${PID_FILE}" && -d "/proc/$(cat "${PID_FILE}")" ]]; then
|
||||
echo "pulse-sensor-proxy.service active"
|
||||
exit 0
|
||||
fi
|
||||
echo "pulse-sensor-proxy.service inactive"
|
||||
exit 3
|
||||
;;
|
||||
is-active)
|
||||
if [[ -f "${PID_FILE}" && -d "/proc/$(cat "${PID_FILE}")" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 3
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "${STUB_DIR}/systemctl"
|
||||
}
|
||||
|
||||
assert_file_contains() {
|
||||
local file="$1"
|
||||
local text="$2"
|
||||
if ! grep -Fq "$text" "$file"; then
|
||||
echo "Assertion failed: \"$text\" not found in $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_exists() {
|
||||
local target="$1"
|
||||
if [[ -e "$target" ]]; then
|
||||
echo "Assertion failed: expected $target to be absent" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_curl_stub
|
||||
create_systemctl_stub
|
||||
export PATH="${STUB_DIR}:$PATH"
|
||||
|
||||
if [[ ! -f "${LOCAL_BIN}" ]]; then
|
||||
echo "Local binary not found at ${LOCAL_BIN}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PULSE_FORCE_INTERACTIVE=1 bash "${INSTALLER}" \
|
||||
--standalone \
|
||||
--http-mode \
|
||||
--pulse-server http://pulse.local:7655 \
|
||||
--local-binary "${LOCAL_BIN}" >/tmp/install.log 2>&1
|
||||
|
||||
CONFIG_FILE="/etc/pulse-sensor-proxy/config.yaml"
|
||||
assert_file_contains "${CONFIG_FILE}" "http_enabled: true"
|
||||
assert_file_contains "${CONFIG_FILE}" "http_auth_token: \"INTEGRATION-TOKEN\""
|
||||
assert_file_contains "${CONFIG_FILE}" "127.0.0.1/32"
|
||||
|
||||
/usr/bin/curl -k -s \
|
||||
-H "Authorization: Bearer INTEGRATION-TOKEN" \
|
||||
https://127.0.0.1:8443/health >/tmp/health.json
|
||||
assert_file_contains /tmp/health.json '"status":"ok"'
|
||||
|
||||
PULSE_FORCE_INTERACTIVE=1 bash "${INSTALLER}" --uninstall --purge >/tmp/uninstall.log 2>&1
|
||||
assert_not_exists /usr/local/bin/pulse-sensor-proxy
|
||||
assert_not_exists /etc/systemd/system/pulse-sensor-proxy.service
|
||||
|
||||
echo "Sensor proxy HTTP installation smoke test passed."
|
||||
EOS
|
||||
chmod +x "${CONTAINER_SCRIPT}"
|
||||
|
||||
log "Running sensor-proxy installer test in ${DOCKER_IMAGE}"
|
||||
docker run --rm \
|
||||
-v "${ROOT_DIR}:/workspace:ro" \
|
||||
-v "${ARTIFACT_DIR}:/artifacts:ro" \
|
||||
-e SENSOR_PROXY_LOCAL_BINARY="/artifacts/pulse-sensor-proxy-linux-amd64" \
|
||||
"${DOCKER_IMAGE}" bash -s <"${CONTAINER_SCRIPT}"
|
||||
|
||||
log "Test completed successfully."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loading…
Add table
Add a link
Reference in a new issue