Improve temperature proxy diagnostics and tests

This commit is contained in:
rcourtman 2025-11-13 22:31:53 +00:00
parent e178ae50a5
commit 61f011af1d
9 changed files with 600 additions and 21 deletions

1
.gitignore vendored
View file

@ -103,6 +103,7 @@ screenshots/
.devdata/
test-*.js
test-*.sh
!scripts/tests/test-sensor-proxy-http.sh
test-*.html
*.backup.*
.env.dev

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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