#!/usr/bin/env node // Local workstation agent: collect one snapshot and POST it to /hosts/. // Runs from cron every 5 minutes on the ThinkPad. Zero runtime dependencies — // node:http / node:https only, same posture as the server it talks to. // // Two properties are load-bearing and easy to break by accident. // // * NO QUEUE, NO REPLAY. One run = one attempt. A missed beat is replaced by // the next one five minutes later; a stale heartbeat has no value, and a // spool file would only ever let the dashboard display a past that is no // longer true. Every failure exits non-zero and stops there. // * THE FAILURE PATH NEVER PRINTS A REQUEST OBJECT. run-push.sh pipes this // process' output into `logger -t host-agent`, so anything written here // lands in /var/log/syslog and journald permanently. An error object or an // options bag carrying the Authorization header would persist the ingest // token on disk, in cleartext, forever. Failures are therefore rebuilt from // a fixed template plus err.code or the HTTP status — nothing else. // __tests__/agent.test.js asserts no fragment of the token survives into // the process output. const os = require("node:os"); const http = require("node:http"); const https = require("node:https"); const path = require("node:path"); const DEFAULT_TIMEOUT_MS = 10000; // Exit codes are the only signal cron has. 1 means "the install is wrong" // (fix the env file), 2 means "the push did not land" (network or server). const EXIT_CONFIG = 1; const EXIT_PUSH = 2; // Mirrors HOST_ID_PATTERN in index.js. Checked client-side so a typo in the env // file fails with a readable message instead of an opaque 404 from the server. const HOST_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; // --- Payload ---------------------------------------------------------------- function pickUsage(value) { return { totalGB: value.totalGB, usedGB: value.usedGB, freeGB: value.freeGB, usagePercent: value.usagePercent, }; } // Build the POST /hosts/ body: the GET /health payload minus `logto` and // `timestamp`. Fields are copied one by one rather than spread, so a future // addition to collectMetrics() cannot silently widen what leaves the // workstation. `uptime` is floored exactly like getHealth() does. function buildSnapshot(metrics) { return { hostname: os.hostname(), uptime: Math.floor(os.uptime()), cpu: { model: metrics.cpu.model, cores: metrics.cpu.cores, loadAvg: metrics.cpu.loadAvg, usagePercent: metrics.cpu.usagePercent, }, memory: pickUsage(metrics.memory), disk: pickUsage(metrics.disk), }; } // --- Failure messages ------------------------------------------------------- // // The ONLY three places a failure string is built. Keep it that way: every // caller below reports through one of these, none of them accepts an error // object, and none of them is given the token or the request options. function configFailure(reason) { return new Error(`configuration error: ${reason}`); } function transportFailure(code) { return new Error(`push failed: request error (code=${code || "UNKNOWN"})`); } function httpFailure(statusCode) { return new Error(`push failed: HTTP ${statusCode}`); } // --- Config ----------------------------------------------------------------- function readConfig(env) { const apiUrl = (env.HOSTS_API_URL || "").trim(); const token = env.HOSTS_INGEST_TOKEN || ""; const hostId = (env.HOST_ID || "").trim(); if (!apiUrl) return { error: configFailure("HOSTS_API_URL is not set") }; if (!token) return { error: configFailure("HOSTS_INGEST_TOKEN is not set") }; if (!hostId) return { error: configFailure("HOST_ID is not set") }; if (!HOST_ID_RE.test(hostId)) { return { error: configFailure("HOST_ID must match ^[a-z0-9][a-z0-9-]{0,31}$"), }; } // Shape-check the URL here too, so a broken env file exits 1 ("fix the // install") rather than 2 ("the network is having a bad day"). postSnapshot // repeats the check because it is also called directly by the tests. let target; try { target = buildTargetUrl(apiUrl, hostId); } catch { return { error: configFailure("HOSTS_API_URL is not a valid URL") }; } if (target.protocol !== "http:" && target.protocol !== "https:") { return { error: configFailure("HOSTS_API_URL must be http:// or https://") }; } const timeoutRaw = parseInt(env.HOST_AGENT_TIMEOUT_MS || "", 10); const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_TIMEOUT_MS; return { config: { apiUrl, token, hostId, timeoutMs } }; } // --- Delivery --------------------------------------------------------------- function buildTargetUrl(apiUrl, hostId) { const base = String(apiUrl).replace(/\/+$/, ""); return new URL(`${base}/hosts/${hostId}`); } // One attempt, hard-bounded in wall-clock time. Resolves to { statusCode } on // 2xx, rejects with a template message otherwise. No retry lives here or above. function postSnapshot({ apiUrl, token, hostId, snapshot, timeoutMs = DEFAULT_TIMEOUT_MS }) { return new Promise((resolve, reject) => { let target; try { target = buildTargetUrl(apiUrl, hostId); } catch { reject(configFailure("HOSTS_API_URL is not a valid URL")); return; } const client = target.protocol === "https:" ? https : target.protocol === "http:" ? http : null; if (!client) { reject(configFailure("HOSTS_API_URL must be http:// or https://")); return; } const body = Buffer.from(JSON.stringify(snapshot), "utf-8"); let settled = false; let req = null; const finish = (fn, arg) => { if (settled) return; settled = true; clearTimeout(timer); fn(arg); }; // Wall-clock ceiling over the whole exchange: DNS, connect, TLS, response. // The `timeout` request option only covers socket inactivity, which can add // up past the budget on a flaky link. destroy() surfaces as an 'error' with // the code below, so the timeout reports as ETIMEDOUT and nothing else. const timer = setTimeout(() => { if (settled || !req) return; req.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" })); }, timeoutMs); try { req = client.request( target, { method: "POST", headers: { "Content-Type": "application/json", "Content-Length": body.length, Authorization: `Bearer ${token}`, }, }, (res) => { const { statusCode } = res; // Drain without reading: the response body is never inspected and // never logged. A 400 says the payload was rejected; the reason is in // the server's logs, where it can be read without a token nearby. res.resume(); res.on("end", () => { if (statusCode >= 200 && statusCode < 300) { finish(resolve, { statusCode }); } else { finish(reject, httpFailure(statusCode)); } }); res.on("error", (err) => finish(reject, transportFailure(err && err.code))); }, ); } catch (err) { // request() throws synchronously on an unusable header — in practice a // token with a trailing newline, the classic env-file mistake. Node's // ERR_INVALID_CHAR names the header but never quotes its value; it is // still converted here rather than propagated, so no error object from // the HTTP stack can ever reach a console call. if (err && err.code === "ERR_INVALID_CHAR") { finish( reject, configFailure( "HOSTS_INGEST_TOKEN contains an invalid character (a trailing newline in the env file is the usual cause)", ), ); } else { finish(reject, transportFailure(err && err.code)); } return; } // err.code and NOTHING else. err.message can carry the resolved address, // and the error object dragged into a console call would carry the whole // request — headers included. req.on("error", (err) => finish(reject, transportFailure(err && err.code))); req.end(body); }); } // --- Entry point ------------------------------------------------------------ // metrics.js is shared with the server and lives one directory up. The frozen // install copy must keep that layout (see README) — when it does not, say so in // one line rather than let cron mail a MODULE_NOT_FOUND stack. function loadCollectMetrics() { try { return require(path.join(__dirname, "..", "metrics.js")).collectMetrics; } catch (err) { if (err && err.code === "MODULE_NOT_FOUND") { throw configFailure( "metrics.js not found one level above the agent — the install copy must keep the repo layout (see agent/README.md)", ); } throw err; } } async function main(argv = process.argv.slice(2), env = process.env) { // Dry run: collect and print the payload, touch no network, need no config. // This is what commissioning runs first, before the env file even exists. const dryRun = argv.includes("--dry-run"); // Config first: the CPU sample below costs 500ms, and there is no point // spending it to then discover HOST_ID is a typo. let config = null; if (!dryRun) { const result = readConfig(env); if (result.error) { console.error(`host-agent: ${result.error.message}`); return EXIT_CONFIG; } config = result.config; } let snapshot; try { const collectMetrics = loadCollectMetrics(); snapshot = buildSnapshot(await collectMetrics()); } catch (err) { console.error(`host-agent: ${err.message}`); return EXIT_CONFIG; } if (dryRun) { console.log(JSON.stringify(snapshot, null, 2)); return 0; } try { const { statusCode } = await postSnapshot({ ...config, snapshot }); console.log( `host-agent: ok id=${config.hostId} status=${statusCode} hostname=${snapshot.hostname}`, ); return 0; } catch (err) { console.error(`host-agent: ${err.message}`); return EXIT_PUSH; } } if (require.main === module) { main().then( (code) => { process.exitCode = code; }, (err) => { // Nothing should reach here — every path above is handled. Print the // message only, never the error object: a stack rendered by console.error // is fine, but an object argument is what leaks headers. console.error(`host-agent: unexpected failure: ${err && err.message}`); process.exitCode = EXIT_PUSH; }, ); } module.exports = { buildSnapshot, buildTargetUrl, postSnapshot, readConfig, main, DEFAULT_TIMEOUT_MS, EXIT_CONFIG, EXIT_PUSH, HOST_ID_RE, };