Zero-dependency local agent: collect one snapshot through the shared metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five minutes. Nothing is queued and nothing is replayed — a heartbeat from five minutes ago describes a machine that no longer exists, so a failed push is logged and dropped rather than spooled. run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never enables shell tracing: the script is meant to be piped into `logger`, where `set -x` would echo the ingest token into /var/log/syslog and journald for good. Same reason the failure path reports only err.code and the HTTP status — never an error object, request options, headers, or a response body. The cost is accepted: a 400 says the payload was rejected, not why. 50 tests, including two that spawn the real process against a failing server and scan its actual stdout and stderr for any five-character fragment of the token. The payload is pinned against the server's own sanitizeSnapshot(), and one case pushes a real collectMetrics() snapshot through the real ingestion handler, so a drift between agent and server turns a test red instead of producing a 400 at 3 a.m. on the ThinkPad. agent/ stays out of the Docker image: the COPY line is untouched, and a test pins that it copies files one by one and never names the directory. Installing on a workstation — frozen copy, env file, crontab line, dry run, and why two machines must never share a HOST_ID — is documented in agent/README.md. The install itself belongs to the commissioning issue. Resolves #15
309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Local workstation agent: collect one snapshot and POST it to /hosts/<id>.
|
|
// 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/<id> 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,
|
|
};
|