Add SshHostCollector for .180 (omv) and .171 (ripper)
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:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user