Add SshHostCollector for .180 (omv) and .171 (ripper)
CI / web (push) Successful in 20s
CI / api (push) Successful in 27s

Extends monitoring to the two bare-metal boxes Proxmox can't see.
Uses a dedicated ed25519 key with a forced authorized_keys command
(see docs/ssh-collector-key-setup.md) so a leaked key can only ever
run the fixed read-only stats script, never arbitrary commands.

CPU is approximated from 1-min load average / core count (a true
utilization % would need two /proc/stat samples).

Closes #8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 20:40:10 -06:00
parent ac768ed0db
commit 7df08cd16d
10 changed files with 349 additions and 0 deletions
+149
View File
@@ -0,0 +1,149 @@
import { readFileSync } from "node:fs";
import { Client } from "ssh2";
import type { Collector, MetricSnapshot } from "./types.js";
export interface DiskPath {
path: string;
label: string;
}
export interface SshHostConfig {
id: string;
displayName: string;
group: string;
host: string;
port: number;
username: string;
privateKeyPath: string;
diskPaths: DiskPath[];
}
// Generic Linux boxes Proxmox can't see (bare metal, not LXCs). Pulls the same
// shape of data as ProxmoxCollector via one SSH exec of read-only commands.
// CPU is approximated from 1-min load average / core count since a true
// utilization % needs two /proc/stat samples, not worth a second round trip here.
export class SshHostCollector implements Collector {
readonly id: string;
private readonly privateKey: Buffer;
constructor(private readonly cfg: SshHostConfig) {
this.id = `ssh:${cfg.id}`;
this.privateKey = readFileSync(cfg.privateKeyPath);
}
async collect(): Promise<MetricSnapshot[]> {
try {
const output = await this.runRemoteScript();
return [this.parse(output)];
} catch (err) {
return [
{
hostId: `ssh:${this.cfg.id}`,
displayName: this.cfg.displayName,
group: this.cfg.group,
status: "down",
cpuPct: null,
memPct: null,
diskPct: null,
memPressurePct: null,
cpuPressurePct: null,
uptimeSec: null,
meta: { error: err instanceof Error ? err.message : String(err) },
},
];
}
}
// The remote authorized_keys entry for this key sets a forced `command=`, so
// whatever we exec here is ignored server-side — the actual read-only script
// lives on the remote host (see docs/ssh-collector-key-setup.md) and must stay
// in sync with `diskPaths` labels below. This is intentional defense in depth:
// a leaked key can only ever run that fixed script, not arbitrary commands.
private runRemoteScript(): Promise<string> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timeout = setTimeout(() => {
conn.end();
reject(new Error("ssh timeout"));
}, 10_000);
conn
.on("ready", () => {
conn.exec("collect-stats", (err, stream) => {
if (err) {
clearTimeout(timeout);
conn.end();
return reject(err);
}
let stdout = "";
stream
.on("close", () => {
clearTimeout(timeout);
conn.end();
resolve(stdout);
})
.on("data", (data: Buffer) => {
stdout += data.toString();
});
});
})
.on("error", (err) => {
clearTimeout(timeout);
reject(err);
})
.connect({
host: this.cfg.host,
port: this.cfg.port,
username: this.cfg.username,
privateKey: this.privateKey,
readyTimeout: 8_000,
});
});
}
private parse(output: string): MetricSnapshot {
const vars = new Map<string, string>();
for (const line of output.split("\n")) {
const eq = line.indexOf("=");
if (eq === -1) continue;
vars.set(line.slice(0, eq), line.slice(eq + 1));
}
const nproc = Number(vars.get("NPROC") ?? "1") || 1;
const load1 = Number(vars.get("LOAD1") ?? "0");
const cpuPct = clamp(round((load1 / nproc) * 100));
const [memTotal, memUsed] = (vars.get("MEMLINE") ?? "0:0").split(":").map(Number);
const memPct = memTotal > 0 ? round((memUsed / memTotal) * 100) : null;
const uptimeSec = Number(vars.get("UPTIME") ?? "0") || null;
const disks = this.cfg.diskPaths.map((d) => {
const [size, used] = (vars.get(`DISK_${d.label}`) ?? "0:0").split(":").map(Number);
return { label: d.label, pct: size > 0 ? round((used / size) * 100) : null };
});
const primaryDiskPct = disks[0]?.pct ?? null;
return {
hostId: `ssh:${this.cfg.id}`,
displayName: this.cfg.displayName,
group: this.cfg.group,
status: "up",
cpuPct,
memPct,
diskPct: primaryDiskPct,
memPressurePct: null,
cpuPressurePct: null,
uptimeSec,
meta: { disks, loadApproximation: true },
};
}
}
function round(n: number): number {
return Math.round(n * 10) / 10;
}
function clamp(n: number): number {
return Math.min(Math.max(n, 0), 100);
}
+26
View File
@@ -1,11 +1,23 @@
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import type { SshHostConfig } from "../collectors/sshHost.js";
interface RawSshHost {
id: string;
displayName: string;
group: string;
host: string;
port?: number;
username: string;
diskPaths?: { path: string; label: string }[];
}
export interface HostsConfig {
proxmox: {
host: string;
node: string;
};
sshHosts?: RawSshHost[];
}
export interface AppConfig {
@@ -26,6 +38,7 @@ export interface AppConfig {
tokenId: string;
tokenSecret: string;
};
sshHosts: SshHostConfig[];
hosts: HostsConfig;
}
@@ -37,6 +50,18 @@ function required(name: string): string {
export function loadConfig(hostsConfigPath: string): AppConfig {
const hosts = parse(readFileSync(hostsConfigPath, "utf8")) as HostsConfig;
const sshPrivateKeyPath = process.env.SSH_PRIVATE_KEY_PATH ?? "./ssh/monitor_ed25519";
const sshHosts: SshHostConfig[] = (hosts.sshHosts ?? []).map((h) => ({
id: h.id,
displayName: h.displayName,
group: h.group,
host: h.host,
port: h.port ?? 22,
username: h.username,
privateKeyPath: sshPrivateKeyPath,
diskPaths: h.diskPaths ?? [{ path: "/", label: "root" }],
}));
return {
port: Number(process.env.PORT ?? 3000),
@@ -54,6 +79,7 @@ export function loadConfig(hostsConfigPath: string): AppConfig {
tokenId: required("PROXMOX_TOKEN_ID"),
tokenSecret: required("PROXMOX_TOKEN_SECRET"),
},
sshHosts,
hosts,
};
}
+2
View File
@@ -6,6 +6,7 @@ import { loadConfig } from "./config/index.js";
import { openDb, upsertSnapshots, pruneOldSnapshots } from "./db/index.js";
import { seedAdminUser } from "./auth/local.js";
import { ProxmoxCollector } from "./collectors/proxmox.js";
import { SshHostCollector } from "./collectors/sshHost.js";
import type { Collector } from "./collectors/types.js";
import { registerAuthRoutes } from "./routes/auth.js";
import { registerHostRoutes } from "./routes/hosts.js";
@@ -27,6 +28,7 @@ async function main() {
tokenId: cfg.proxmox.tokenId,
tokenSecret: cfg.proxmox.tokenSecret,
}),
...cfg.sshHosts.map((h) => new SshHostCollector(h)),
];
async function pollOnce() {