vps-health-api/index.js
le king fu fedb1c81dc feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts)
Open the API's first write surface. Workstations push their CPU / memory /
disk snapshot, the admin dashboard reads it back with a server-computed
freshness.

Routing moves from an exact path list to a descriptor table, keeping ONE
authentication checkpoint:

  resolveRoute()  -> no match means an immediate 404, deny by default
  checkAuth()     -> the single gate; only the expected token varies per
                     descriptor (HEALTH_TOKEN for reads, HOSTS_INGEST_TOKEN
                     for ingestion)
  dispatch

The routing-before-auth ordering is preserved on purpose: an unknown path or
a wrong method still answers 404, never 401, exactly as before. The two
`404 (not 401)` tests added in #12 pin that ordering and still pass.

Security controls on the new write path:

- The route regex ^/hosts/([a-z0-9][a-z0-9-]{0,31})$, POST-only, IS the path
  traversal control. URL() normalises /hosts/../../etc/passwd to /etc/passwd
  and encoded traversals fail the match, so every hostile id lands on 404.
  403 is reserved for well-formed ids outside the allowlist.
- 401 wins over 403, so an unauthenticated caller cannot probe the allowlist.
- tokenMatches() compares SHA-256 digests with timingSafeEqual. Scoped to the
  ingestion path only; realigning HEALTH_TOKEN touches every read route in
  production and is tracked separately.
- Body capped at 4 KiB by counting received bytes, never Content-Length:
  a client can lie in the header and chunked encoding omits it. Past the cap,
  413 then req.destroy() once the response has flushed.
- The persisted object is rebuilt field by field from a whitelist — finite
  numbers, strings capped at 128 chars, everything else dropped — because the
  file is read back by GET /hosts and ends up in the admin React tree. The
  read path re-runs the same whitelist: the bind-mount is writable, so the
  file on disk earns no more trust than the payload did.
- Snapshots are written to a temp file in the same directory then renamed, so
  a concurrent GET /hosts can never observe a truncated JSON.
- HOSTS_ALLOWED_IDS entries are validated at startup with the same regex;
  rejects are logged and dropped rather than becoming file paths.
- Every 401/403 is logged with X-Real-IP, the host id and the reason. Log
  values are filtered to printable ASCII so a crafted header cannot forge
  extra log lines.
- receivedAt is stamped by the server on arrival; a client-supplied timestamp
  is discarded by the whitelist. online = ageSeconds <= HOSTS_STALE_SECONDS.

Runtime stays zero-dependency — node:crypto is a builtin and no new module
file was added, so the Dockerfile's explicit COPY list is unchanged.

Tests: 61 (39 existing untouched + 22 new). __tests__/hosts.test.js is smoke
coverage of the decisions that would be silent to regress; the exhaustive
matrix is issue #14.

Docs: .env.example gains HOSTS_DIR / HOSTS_ALLOWED_IDS / HOSTS_INGEST_TOKEN /
HOSTS_STALE_SECONDS; CLAUDE.md and README.md document the endpoints, the
routing/auth ordering and the config. The CLAUDE.md "read-only" gotcha is
corrected — the API now writes, and HOSTS_DIR must be writable by uid 1000.

Resolves #13
2026-08-16 11:49:36 -04:00

745 lines
26 KiB
JavaScript

const http = require("node:http");
const os = require("node:os");
const {
readFileSync,
readdirSync,
existsSync,
mkdirSync,
writeFileSync,
renameSync,
unlinkSync,
} = require("node:fs");
const path = require("node:path");
const { createHash, timingSafeEqual } = require("node:crypto");
const { collectMetrics } = require("./metrics.js");
const PORT = parseInt(process.env.PORT || "3001", 10);
const TOKEN = process.env.HEALTH_TOKEN;
const REPORTS_DIR = process.env.REPORTS_DIR || "/data/defenseurs/reports";
const AGENTS_MAP_PATH =
process.env.DEFENSEURS_AGENTS_MAP_PATH || "/data/defenseurs/agents-map.json";
const LOGTO_HEALTH_URL =
process.env.LOGTO_HEALTH_URL ||
"https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration";
const LOGTO_TIMEOUT_MS = 3000;
const SCAN_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
const VALID_SEVERITIES = Object.keys(SEVERITY_RANK);
const VALID_CATEGORIES = ["deps", "secrets", "code", "acces", "infra"];
// --- Workstation snapshots (POST /hosts/<id>, GET /hosts) -------------------
const HOSTS_DIR = process.env.HOSTS_DIR || "/data/hosts";
const HOSTS_INGEST_TOKEN = process.env.HOSTS_INGEST_TOKEN;
const MAX_BODY_BYTES = 4096;
const MAX_STRING_LENGTH = 128;
// Single source of truth for what a host id may look like. The allowlist check
// and the route matcher are both built from it, so they can never drift apart.
//
// This narrow pattern IS the path-traversal control. Verified in Node: URL()
// normalises `/hosts/../../etc/passwd` to `/etc/passwd`, while `..%2f..%2f` and
// `THINKPAD` simply fail the match — every hostile id therefore lands on 404,
// before any handler runs. 403 is reserved for well-formed ids that are not in
// the allowlist. Never widen this to `^/hosts/(.+)$` to make a test pass.
const HOST_ID_PATTERN = "[a-z0-9][a-z0-9-]{0,31}";
const HOST_ID_RE = new RegExp(`^${HOST_ID_PATTERN}$`);
const HOST_ROUTE_RE = new RegExp(`^/hosts/(${HOST_ID_PATTERN})$`);
const staleSecondsRaw = parseInt(process.env.HOSTS_STALE_SECONDS || "", 10);
const HOSTS_STALE_SECONDS =
Number.isFinite(staleSecondsRaw) && staleSecondsRaw > 0 ? staleSecondsRaw : 900;
// Parse HOSTS_ALLOWED_IDS once at startup and drop anything that is not a valid
// host id. Entries feed path.join(HOSTS_DIR, `${id}.json`), so a stray space or
// an entry like `../defenseurs/status` must never survive into a file path.
// Rejections are logged rather than silently swallowed: a typo in the env var
// would otherwise look exactly like a workstation that never checked in.
function parseAllowedIds(raw) {
const allowed = new Set();
for (const entry of String(raw).split(",")) {
const id = entry.trim();
if (!id) continue;
if (!HOST_ID_RE.test(id)) {
console.warn(
`WARNING: HOSTS_ALLOWED_IDS entry rejected (not a valid host id): ${safeLogValue(id)}`,
);
continue;
}
allowed.add(id);
}
return allowed;
}
const HOSTS_ALLOWED_IDS = parseAllowedIds(process.env.HOSTS_ALLOWED_IDS || "thinkpad");
// Severity filter. Asymmetric rule (issue #3):
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
// - threshold "INFO" -> INFO only (explicit opt-in)
// - any other -> everything at or above threshold, EXCEPT INFO
// INFO is therefore reachable only via explicit ?severity=INFO.
function allowedSeverities(threshold) {
if (!threshold) return ["CRITICAL", "HIGH", "MEDIUM"];
if (threshold === "INFO") return ["INFO"];
const min = SEVERITY_RANK[threshold];
return VALID_SEVERITIES.filter(
(s) => s !== "INFO" && SEVERITY_RANK[s] >= min,
);
}
if (!TOKEN) {
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
}
// No startup warning for a missing HOSTS_INGEST_TOKEN: the ingestion path
// fail-closes to 503 and logs the reason on the request that hits it, which is
// the moment an operator actually needs to see it.
async function getLogtoHealth() {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), LOGTO_TIMEOUT_MS);
const start = performance.now();
try {
const res = await fetch(LOGTO_HEALTH_URL, { signal: ac.signal });
const responseTimeMs = Math.round(performance.now() - start);
if (res.ok) return { status: "up", responseTimeMs };
return { status: "down", responseTimeMs, error: `HTTP ${res.status}` };
} catch (err) {
const responseTimeMs = Math.round(performance.now() - start);
const error = err.name === "AbortError" ? "timeout" : err.message || "network error";
return { status: "down", responseTimeMs, error };
} finally {
clearTimeout(timer);
}
}
// Reproduce the isScanReport guard from defenseurs/src/report.ts. The
// defenseur-auto run report has shape { actions[], skipped[] } with no
// findings[] — it must be filtered out so it never reaches the auto pipeline
// (which expects scan-shaped reports only).
function isScanReport(value) {
return (
typeof value === "object" &&
value !== null &&
Array.isArray(value.findings) &&
typeof value.agent === "string" &&
typeof value.timestamp === "string"
);
}
// Read all `defenseur-<agent>_<date>*.json` files under `dir` matching the
// given UTC date. Returns parsed scan reports keyed by filename so the caller
// can dedupe across REPORTS_DIR + REPORTS_DIR/archive.
function collectScanReportsFromDir(dir, date) {
const collected = new Map();
if (!existsSync(dir)) return collected;
const files = readdirSync(dir).filter(
(f) => f.startsWith("defenseur-") && f.includes(`_${date}`) && f.endsWith(".json"),
);
for (const file of files) {
try {
const raw = readFileSync(path.join(dir, file), "utf-8");
const parsed = JSON.parse(raw);
if (!isScanReport(parsed)) continue;
if (!parsed.timestamp.startsWith(date)) continue;
collected.set(file, parsed);
} catch (err) {
console.error(`[reports/scans] failed to parse ${file}:`, err.message);
}
}
return collected;
}
// Read all `defenseur-<agent>_<date>*.json` files under REPORTS_DIR for the
// given UTC date. The scan reports use an ISO timestamp with `:` and `.`
// rewritten as `-` in the filename (e.g. defenseur-booking_2026-05-06T05-30-11-249Z.json).
// We match `_<date>` then re-confirm via parsed.timestamp.startsWith(date).
//
// The Sergent rotates fresh reports out of REPORTS_DIR into REPORTS_DIR/archive
// at 07:30 UTC daily (cf. defenseurs/src/sergent.ts renameSync). For ~22h/day
// the only copy lives in archive/ — so we scan both and concatenate. Top-level
// files take precedence on filename collision (more recent by definition).
function readScanReportsForDate(date) {
const topLevel = collectScanReportsFromDir(REPORTS_DIR, date);
const archive = collectScanReportsFromDir(path.join(REPORTS_DIR, "archive"), date);
// Merge with top-level priority — only insert archive entries whose filename
// is not already present at the top level.
for (const [file, report] of archive) {
if (!topLevel.has(file)) topLevel.set(file, report);
}
// Stable sort by timestamp asc — same convention as readReports() in
// defenseurs/src/report.ts.
return [...topLevel.values()].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
}
// Find the latest scan report for a given agent across REPORTS_DIR and
// REPORTS_DIR/archive. Files are named `defenseur-<agent>_<iso>.json`.
// The trailing underscore in the prefix prevents collisions on agent names
// that share a prefix (e.g. "booking" vs "booking-staging").
function findLatestReportForAgent(agent) {
// `agent` is the full identifier as written by the Sergent into
// agents-map.json (e.g. "defenseur-booking") — matches both the JSON
// `agent` field and the filename prefix `<agent>_<iso>.json`.
const prefix = `${agent}_`;
const dirs = [REPORTS_DIR, path.join(REPORTS_DIR, "archive")];
let latest = null;
for (const dir of dirs) {
if (!existsSync(dir)) continue;
const files = readdirSync(dir).filter(
(f) => f.startsWith(prefix) && f.endsWith(".json"),
);
for (const file of files) {
try {
const raw = readFileSync(path.join(dir, file), "utf-8");
const parsed = JSON.parse(raw);
if (!isScanReport(parsed)) continue;
if (parsed.agent !== agent) continue;
const ts = new Date(parsed.timestamp).getTime();
if (Number.isNaN(ts)) continue;
if (!latest || ts > latest._ts) {
latest = parsed;
latest._ts = ts;
}
} catch (err) {
console.error(`[defenseurs/findings] failed to parse ${file}:`, err.message);
}
}
}
if (latest) delete latest._ts;
return latest;
}
async function getHealth() {
// Keep the Promise.all: the 500ms CPU sample inside collectMetrics() and the
// up-to-3s Logto check must stay concurrent. Awaiting them one after the
// other would push the p99 of /health to ~3.5s.
const [metrics, logto] = await Promise.all([
collectMetrics(),
getLogtoHealth(),
]);
return {
timestamp: new Date().toISOString(),
hostname: os.hostname(),
uptime: Math.floor(os.uptime()),
...metrics,
logto,
};
}
// --- Hosts helpers ----------------------------------------------------------
// Printable-ASCII-only, length-capped rendering for anything attacker-supplied
// that reaches a log line (X-Real-IP, host id, env entries). Keeps a crafted
// value from forging extra log records.
function safeLogValue(value, max = 64) {
return String(value).replace(/[^\x20-\x7e]/g, "?").slice(0, max);
}
// Log every 401/403. This is the first publicly writable surface on the API:
// without a trace, a token-guessing campaign would leave nothing behind.
function logRejection(req, status, reason, id) {
const ip = safeLogValue(req.headers["x-real-ip"] || req.socket?.remoteAddress || "unknown");
const method = safeLogValue(req.method, 8);
const url = safeLogValue(req.url, 120);
console.warn(
`[auth] ${status} ${method} ${url} ip=${ip} id=${id ? safeLogValue(id, 32) : "-"} reason=${reason}`,
);
}
// Constant-time bearer comparison for the ingestion path. Both sides are hashed
// first, so the buffers are always 32 bytes: timingSafeEqual never throws on
// mismatched lengths, and the length of the real token cannot leak.
//
// Deliberately scoped to the new write path. Realigning the existing
// HEALTH_TOKEN comparison touches every read route in production and is tracked
// separately (issue #17).
function tokenMatches(provided, expected) {
if (typeof provided !== "string" || typeof expected !== "string") return false;
const a = createHash("sha256").update(provided).digest();
const b = createHash("sha256").update(expected).digest();
return timingSafeEqual(a, b);
}
// Read the request body with a hard 4 KiB ceiling. The counter runs on the
// bytes actually received, never on Content-Length: a client can lie in the
// header, and chunked transfer-encoding omits it altogether.
//
// Resolves to the raw string, or to null when the response has already been
// written (413) or the socket died — in which case the caller must not answer.
function readBody(req, res) {
return new Promise((resolve) => {
const chunks = [];
let size = 0;
let settled = false;
req.on("data", (chunk) => {
if (settled) return;
size += chunk.length;
if (size > MAX_BODY_BYTES) {
settled = true;
req.pause();
res.writeHead(413);
// Destroy only once the 413 has been flushed: req.destroy() tears down
// the socket, so cutting first would leave the client with a network
// error instead of a status code.
res.end(JSON.stringify({ error: "Payload too large" }), () => req.destroy());
resolve(null);
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks).toString("utf-8"));
});
req.on("error", () => {
if (settled) return;
settled = true;
resolve(null);
});
});
}
function cleanString(value) {
if (typeof value !== "string") return null;
return value.slice(0, MAX_STRING_LENGTH);
}
function cleanNumber(value) {
return Number.isFinite(value) ? value : null;
}
// { totalGB, usedGB, freeGB, usagePercent } — the shape shared by memory and disk.
function sanitizeUsage(value, label) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return { error: `${label} must be an object` };
}
const out = {};
for (const key of ["totalGB", "usedGB", "freeGB", "usagePercent"]) {
const num = cleanNumber(value[key]);
if (num === null) return { error: `${label}.${key} must be a finite number` };
out[key] = num;
}
return { value: out };
}
function sanitizeCpu(value) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return { error: "cpu must be an object" };
}
const model = cleanString(value.model);
if (model === null) return { error: "cpu.model must be a string" };
const cores = cleanNumber(value.cores);
if (cores === null) return { error: "cpu.cores must be a finite number" };
const usagePercent = cleanNumber(value.usagePercent);
if (usagePercent === null) return { error: "cpu.usagePercent must be a finite number" };
if (!Array.isArray(value.loadAvg)) return { error: "cpu.loadAvg must be an array" };
const loadAvg = [];
for (const entry of value.loadAvg.slice(0, 3)) {
const num = cleanNumber(entry);
if (num === null) return { error: "cpu.loadAvg must contain finite numbers" };
loadAvg.push(num);
}
return { value: { model, cores, loadAvg, usagePercent } };
}
// Rebuild the persisted object field by field from a whitelist — never store
// the payload verbatim. The file is read back by GET /hosts and ends up in the
// admin dashboard's React tree, so an unknown key (`__proto__` among them) or a
// 4 KiB hostname must never make it that far.
function sanitizeSnapshot(payload) {
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
return { error: "body must be a JSON object" };
}
const hostname = cleanString(payload.hostname);
if (hostname === null) return { error: "hostname must be a string" };
const uptime = cleanNumber(payload.uptime);
if (uptime === null) return { error: "uptime must be a finite number" };
const cpu = sanitizeCpu(payload.cpu);
if (cpu.error) return cpu;
const memory = sanitizeUsage(payload.memory, "memory");
if (memory.error) return memory;
const disk = sanitizeUsage(payload.disk, "disk");
if (disk.error) return disk;
return {
snapshot: {
hostname,
uptime,
cpu: cpu.value,
memory: memory.value,
disk: disk.value,
},
};
}
// Atomic write: temp file in the SAME directory, then renameSync. rename is
// atomic within a filesystem, so a concurrent GET /hosts either sees the old
// snapshot or the new one — never a half-written JSON.
function writeHostSnapshot(id, record) {
mkdirSync(HOSTS_DIR, { recursive: true });
const finalPath = path.join(HOSTS_DIR, `${id}.json`);
const tmpPath = path.join(HOSTS_DIR, `.${id}.${process.pid}.${Date.now()}.tmp`);
try {
writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
renameSync(tmpPath, finalPath);
} catch (err) {
try {
unlinkSync(tmpPath);
} catch {
// Best effort — the temp file may never have been created.
}
throw err;
}
}
// Read one persisted snapshot. Anything missing, unparsable, off-shape or
// without a usable server timestamp degrades to null: GET /hosts then reports
// that entry as neverSeen instead of failing the whole response.
//
// The file was written by this service, but it lives on a writable bind-mount:
// it is re-run through the same whitelist on the way out rather than trusted.
function readHostSnapshot(id) {
const file = path.join(HOSTS_DIR, `${id}.json`);
let parsed;
try {
parsed = JSON.parse(readFileSync(file, "utf-8"));
} catch (err) {
if (err.code !== "ENOENT") {
console.error(`[hosts] failed to read snapshot for ${id}:`, err.message);
}
return null;
}
const result = sanitizeSnapshot(parsed);
if (result.error) {
console.error(`[hosts] snapshot for ${id} rejected on read: ${result.error}`);
return null;
}
const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null;
if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) {
console.error(`[hosts] snapshot for ${id} has no usable receivedAt`);
return null;
}
return { ...result.snapshot, receivedAt };
}
// --- Route handlers ---------------------------------------------------------
async function handleHealth(req, res) {
try {
const data = await getHealth();
res.writeHead(200);
res.end(JSON.stringify(data));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
}
function handleDefenseurs(req, res) {
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
try {
const status = readFileSync(statusPath, "utf-8");
res.writeHead(200);
res.end(status);
} catch {
res.writeHead(200);
res.end(JSON.stringify({ status: "no_data" }));
}
}
function handleFindings(req, res, ctx) {
const project = ctx.parsedUrl.searchParams.get("project");
const category = ctx.parsedUrl.searchParams.get("category");
const severity = ctx.parsedUrl.searchParams.get("severity");
if (!project) {
res.writeHead(400);
res.end(JSON.stringify({ error: "Bad request: project=<name> required" }));
return;
}
if (category && !VALID_CATEGORIES.includes(category)) {
res.writeHead(400);
res.end(JSON.stringify({ error: `Bad request: category must be one of ${VALID_CATEGORIES.join(",")}` }));
return;
}
if (severity && !VALID_SEVERITIES.includes(severity)) {
res.writeHead(400);
res.end(JSON.stringify({ error: `Bad request: severity must be one of ${VALID_SEVERITIES.join(",")}` }));
return;
}
let agentsMap;
try {
agentsMap = JSON.parse(readFileSync(AGENTS_MAP_PATH, "utf-8"));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
return;
}
const agent = agentsMap[project];
if (!agent) {
res.writeHead(404);
res.end(JSON.stringify({ error: `Unknown project: ${project}` }));
return;
}
try {
const report = findLatestReportForAgent(agent);
if (!report) {
res.writeHead(200);
res.end(JSON.stringify({ findings: [], status: "no_data" }));
return;
}
const allowed = new Set(allowedSeverities(severity));
const findings = report.findings.filter(
(f) => allowed.has(f.severity) && (!category || f.category === category),
);
res.writeHead(200);
res.end(JSON.stringify({
agent,
project,
timestamp: report.timestamp,
findings,
}));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
}
function handleReportsScans(req, res, ctx) {
const date = ctx.parsedUrl.searchParams.get("date");
// Regex short-circuit before any filesystem access — blocks path
// traversal (`../../etc/passwd` -> 400) and bogus inputs.
if (!date || !SCAN_DATE_RE.test(date)) {
res.writeHead(400);
res.end(JSON.stringify({ error: "Bad request: date=YYYY-MM-DD required" }));
return;
}
try {
const reports = readScanReportsForDate(date);
res.writeHead(200);
res.end(JSON.stringify({ date, count: reports.length, reports }));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
}
// Serve one entry per allowlisted workstation, whether or not it ever reported.
// Keeping the neverSeen entries visible is what turns the commissioning window
// (deployed, no heartbeat yet) into "agent not installed" on the dashboard
// instead of a silently missing card.
function handleHostsList(req, res) {
const now = Date.now();
const hosts = [];
for (const id of [...HOSTS_ALLOWED_IDS].sort()) {
const record = readHostSnapshot(id);
if (!record) {
hosts.push({ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true });
continue;
}
// Freshness is computed server-side from the server-stamped receivedAt —
// the client never gets a say in whether it looks online.
const ageSeconds = Math.max(
0,
Math.floor((now - new Date(record.receivedAt).getTime()) / 1000),
);
hosts.push({
id,
hostname: record.hostname,
uptime: record.uptime,
cpu: record.cpu,
memory: record.memory,
disk: record.disk,
receivedAt: record.receivedAt,
ageSeconds,
online: ageSeconds <= HOSTS_STALE_SECONDS,
});
}
res.writeHead(200);
res.end(JSON.stringify({ staleAfterSeconds: HOSTS_STALE_SECONDS, hosts }));
}
async function handleHostIngest(req, res, ctx) {
const id = ctx.hostId;
// The id already matched HOST_ROUTE_RE, so 403 here means "well-formed but
// not one of ours" — the only case that reaches this branch.
if (!HOSTS_ALLOWED_IDS.has(id)) {
logRejection(req, 403, "host id not in HOSTS_ALLOWED_IDS", id);
res.writeHead(403);
res.end(JSON.stringify({ error: "Forbidden" }));
return;
}
const raw = await readBody(req, res);
if (raw === null) return; // 413 already answered, or the socket is gone.
let payload;
try {
payload = JSON.parse(raw);
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Bad request: body must be valid JSON" }));
return;
}
const result = sanitizeSnapshot(payload);
if (result.error) {
res.writeHead(400);
res.end(JSON.stringify({ error: `Bad request: ${result.error}` }));
return;
}
// receivedAt is stamped by the SERVER on arrival. A client-supplied timestamp
// would let a stale (or hostile) agent claim to be fresher than it is.
const record = { id, ...result.snapshot, receivedAt: new Date().toISOString() };
try {
writeHostSnapshot(id, record);
res.removeHeader("Content-Type");
res.writeHead(204);
res.end();
} catch (err) {
console.error(`[hosts] failed to persist snapshot for ${id}:`, err.message);
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error" }));
}
}
// --- Routing ----------------------------------------------------------------
// Route descriptors. `tokenKind` is the ONLY thing that varies between the read
// routes and the ingestion route: authentication itself happens once, in the
// single gate below. Adding a route here therefore cannot accidentally ship
// unauthenticated — which is exactly what a per-branch check invites.
const ROUTES = [
{ method: "GET", path: "/health", tokenKind: "read", handle: handleHealth },
{ method: "GET", path: "/defenseurs", tokenKind: "read", handle: handleDefenseurs },
{ method: "GET", path: "/defenseurs/findings", tokenKind: "read", handle: handleFindings },
{ method: "GET", path: "/reports/scans", tokenKind: "read", handle: handleReportsScans },
{ method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList },
{ method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest },
];
// Deny by default: no descriptor match -> null -> 404.
function resolveRoute(method, pathname) {
for (const route of ROUTES) {
if (route.method !== method) continue;
if (route.path !== undefined) {
if (route.path === pathname) return { route, hostId: null };
continue;
}
const match = route.pattern.exec(pathname);
if (match) return { route, hostId: match[1] };
}
return null;
}
// THE single authentication checkpoint. Returns null when the caller may
// proceed, otherwise the rejection to write. Only the expected token varies
// per descriptor.
function checkAuth(req, route) {
const isIngest = route.tokenKind === "ingest";
const expected = isIngest ? HOSTS_INGEST_TOKEN : TOKEN;
if (!expected) {
return isIngest
? { status: 503, body: { error: "HOSTS_INGEST_TOKEN not configured" }, reason: "ingest token not configured" }
: { status: 401, body: { error: "HEALTH_TOKEN not configured" }, reason: "health token not configured" };
}
const header = req.headers["authorization"];
const ok = isIngest
? typeof header === "string" &&
header.startsWith("Bearer ") &&
tokenMatches(header.slice("Bearer ".length), expected)
: header === `Bearer ${expected}`;
if (!ok) {
return { status: 401, body: { error: "Unauthorized" }, reason: "missing or invalid bearer token" };
}
return null;
}
async function handler(req, res) {
res.setHeader("Content-Type", "application/json");
// Parse the URL so /reports/scans can carry a `?date=` query string. The
// placeholder host is required because URL() needs an absolute URL.
const parsedUrl = new URL(req.url, "http://localhost");
const pathname = parsedUrl.pathname;
// ORDER IS PART OF THE CONTRACT: routing resolves FIRST, so an unknown path
// or a wrong method answers 404 — not 401 — exactly as before this route
// table existed. Two tests in __tests__/auth.test.js pin that ordering.
const resolved = resolveRoute(req.method, pathname);
if (!resolved) {
res.writeHead(404);
res.end(JSON.stringify({ error: "Not found" }));
return;
}
const rejection = checkAuth(req, resolved.route);
if (rejection) {
logRejection(req, rejection.status, rejection.reason, resolved.hostId);
res.writeHead(rejection.status);
res.end(JSON.stringify(rejection.body));
return;
}
try {
await resolved.route.handle(req, res, { parsedUrl, hostId: resolved.hostId });
} catch (err) {
// Safety net: every handler already owns its error paths, this only catches
// what none of them anticipated.
console.error(`[handler] unhandled error on ${safeLogValue(pathname, 120)}:`, err.message);
if (!res.headersSent) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error" }));
}
}
}
if (require.main === module) {
const server = http.createServer(handler);
server.listen(PORT, () => {
console.log(`vps-health-api listening on :${PORT}`);
});
}
module.exports = {
handler,
allowedSeverities,
findLatestReportForAgent,
tokenMatches,
sanitizeSnapshot,
HOST_ID_RE,
HOST_ROUTE_RE,
};