const http = require("node:http"); const os = require("node:os"); const { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync, renameSync, unlinkSync, lstatSync, } = require("node:fs"); const path = require("node:path"); const { createHash, timingSafeEqual } = require("node:crypto"); const { collectMetrics } = require("./metrics.js"); const { sanitizeClaudeUsage, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.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/, 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"); // --- Claude usage snapshot (POST /claude-usage, GET /claude-usage) ---------- // Its own token, never the workstations' one: rotated on its own, and a leaked // host token must not be able to overwrite the usage snapshot (or the reverse). // The sanitizer and the contract bounds live in claude-usage.js. const CLAUDE_USAGE_INGEST_TOKEN = process.env.CLAUDE_USAGE_INGEST_TOKEN; const CLAUDE_USAGE_PATH = process.env.CLAUDE_USAGE_PATH || "/data/claude-usage/claude-usage.json"; const claudeStaleRaw = parseInt(process.env.CLAUDE_USAGE_STALE_SECONDS || "", 10); const CLAUDE_USAGE_STALE_SECONDS = Number.isFinite(claudeStaleRaw) && claudeStaleRaw > 0 ? claudeStaleRaw : 2700; // 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-_*.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-_*.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 `_` 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-_.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 `_.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, used for EVERY token (read, both ingests). 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. Passing the raw strings to timingSafeEqual would leak it // (GHSA-376v-xgjx-7mfr). 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 ceiling of `maxBytes`, set per route: 4 KiB // for a host snapshot, 64 KiB for the Claude usage snapshot. 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, maxBytes) { return new Promise((resolve) => { const chunks = []; let size = 0; let settled = false; req.on("data", (chunk) => { if (settled) return; size += chunk.length; if (size > maxBytes) { 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); }); }); } // Truncates, and nothing else: control characters and markup are kept on // purpose, so a legitimate value (a hostname, a CPU model string) is never // mangled. Safety therefore rests on the consumer escaping at render time. The // admin card renders these as React text nodes, which escape them; rendering // one through dangerouslySetInnerHTML or into an href would not be safe. 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" }; // Exactly the 1, 5 and 15-minute averages. os.loadavg() always returns three // entries, and the admin card joins them with " / ": a shorter array would // render a partial line rather than fail, so it is refused here instead. if (!Array.isArray(value.loadAvg)) return { error: "cpu.loadAvg must be an array" }; if (value.loadAvg.length !== 3) return { error: "cpu.loadAvg must hold exactly 3 finite numbers" }; const loadAvg = []; for (const entry of value.loadAvg) { 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. // // The temp name `....tmp` is what sweepStaleTempFiles() matches: // change one, change the other. 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; } } // A process killed between writeFileSync and renameSync (SIGKILL, OOM) leaves // its temp file behind: the catch above never runs. Harmless — GET /hosts only // reads .json — but nothing else would ever remove it, so startup sweeps // the ones older than an hour. Younger ones are left alone: another instance // may still be writing (a rolling redeploy runs two containers on the mount). // // Only the exact temp-name format is touched, and only regular files (lstat, // so a symlink planted on the writable mount is neither followed nor removed). // Best effort throughout: a missing HOSTS_DIR is the normal first boot and // stays silent; any other failure is logged and never stops the server. const TEMP_FILE_RE = new RegExp(`^\\.${HOST_ID_PATTERN}\\.\\d+\\.\\d+\\.tmp$`); const TEMP_MAX_AGE_MS = 60 * 60 * 1000; function sweepStaleTempFiles(now = Date.now()) { let names; try { names = readdirSync(HOSTS_DIR); } catch (err) { if (err.code !== "ENOENT") console.error(`[hosts] temp sweep skipped: ${err.code || err.message}`); return []; } const removed = []; for (const name of names) { if (!TEMP_FILE_RE.test(name)) continue; const file = path.join(HOSTS_DIR, name); try { const stat = lstatSync(file); if (!stat.isFile() || now - stat.mtimeMs <= TEMP_MAX_AGE_MS) continue; unlinkSync(file); removed.push(name); } catch (err) { console.error(`[hosts] temp sweep could not remove ${name}: ${err.code || err.message}`); } } if (removed.length) console.log(`[hosts] swept ${removed.length} stale temp file(s)`); return removed; } // 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 }; } // --- Claude usage helpers --------------------------------------------------- // Same atomic pattern as writeHostSnapshot(): temp file in the SAME directory // (suffixed with the pid, so two containers of a rolling redeploy never share // one), mode 600, then renameSync. A concurrent GET /claude-usage sees the old // snapshot or the new one, never a truncated JSON. function writeClaudeUsage(record) { const dir = path.dirname(CLAUDE_USAGE_PATH); mkdirSync(dir, { recursive: true }); const tmpPath = path.join( dir, `.${path.basename(CLAUDE_USAGE_PATH)}.${process.pid}.${Date.now()}.tmp`, ); try { writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 }); renameSync(tmpPath, CLAUDE_USAGE_PATH); } catch (err) { try { unlinkSync(tmpPath); } catch { // Best effort — the temp file may never have been created. } throw err; } } // Read the stored snapshot back through the same sanitizer: the file lives on // a writable bind-mount and is no more trusted than the payload was. Missing, // unparsable, off-contract or without a usable server timestamp -> null, which // GET /claude-usage turns into { status: "no_data" }. function readClaudeUsage() { let parsed; try { parsed = JSON.parse(readFileSync(CLAUDE_USAGE_PATH, "utf-8")); } catch (err) { if (err.code !== "ENOENT") console.error("[claude-usage] failed to read snapshot:", err.message); return null; } const result = sanitizeClaudeUsage(parsed); if (result.error) { console.error(`[claude-usage] snapshot 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("[claude-usage] snapshot has no usable receivedAt"); return null; } return { snapshot: 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 })); } } // Serves a file written by the Sergent as-is, without re-parsing it: the // Sergent writes atomically and quarantines unreadable state itself. Any read // error (absent, EACCES, EISDIR) degrades to a 200 no_data the reader expects. function serveJsonFile(res, filePath) { try { const content = readFileSync(filePath, "utf-8"); res.writeHead(200); res.end(content); } catch { res.writeHead(200); res.end(JSON.stringify({ status: "no_data" })); } } function handleDefenseurs(req, res) { serveJsonFile(res, process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json"); } function handleDefenseursState(req, res) { serveJsonFile( res, process.env.DEFENSEURS_FINDINGS_STATE_PATH || "/data/defenseurs/findings-state.json", ); } 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= 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, MAX_BODY_BYTES); 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" })); } } async function handleClaudeUsageIngest(req, res) { const raw = await readBody(req, res, CLAUDE_USAGE_MAX_BODY_BYTES); 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 = sanitizeClaudeUsage(payload); if (result.error) { res.writeHead(400); res.end(JSON.stringify({ error: `Bad request: ${result.error}` })); return; } // Server-stamped, like the host snapshots: generatedAt is the workstation's // clock and stays informative, freshness is judged on receivedAt only. const record = { ...result.snapshot, receivedAt: new Date().toISOString() }; try { writeClaudeUsage(record); res.removeHeader("Content-Type"); res.writeHead(204); res.end(); } catch (err) { console.error("[claude-usage] failed to persist snapshot:", err.message); res.writeHead(500); res.end(JSON.stringify({ error: "Internal error" })); } } // Staleness is decided here and only here: the dashboard displays `stale`, it // does not recompute it. function handleClaudeUsageRead(req, res) { const stored = readClaudeUsage(); if (!stored) { res.writeHead(200); res.end(JSON.stringify({ status: "no_data" })); return; } const ageSeconds = Math.max( 0, Math.floor((Date.now() - new Date(stored.receivedAt).getTime()) / 1000), ); res.writeHead(200); res.end(JSON.stringify({ staleAfterSeconds: CLAUDE_USAGE_STALE_SECONDS, receivedAt: stored.receivedAt, ageSeconds, stale: ageSeconds > CLAUDE_USAGE_STALE_SECONDS, snapshot: stored.snapshot, })); } // --- Routing ---------------------------------------------------------------- // Route descriptors. `tokenKind` is the ONLY thing that varies between the read // routes and the ingestion routes: 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: "/defenseurs/state", tokenKind: "read", handle: handleDefenseursState }, { 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 }, { method: "GET", path: "/claude-usage", tokenKind: "read", handle: handleClaudeUsageRead }, { method: "POST", path: "/claude-usage", tokenKind: "claude-ingest", handle: handleClaudeUsageIngest }, ]; // tokenKind -> expected token, and what to answer when that token is not set. // The read token fails closed to 401, as it always has; each ingestion token // fails closed to 503 naming its own variable, so a misconfigured deployment // reports its fault instead of blaming the caller. One entry per token: two // ingestion routes never share one, so neither can open the other. const TOKEN_KINDS = Object.freeze({ __proto__: null, read: { token: TOKEN, missingStatus: 401, missingError: "HEALTH_TOKEN not configured", missingReason: "health token not configured", }, ingest: { token: HOSTS_INGEST_TOKEN, missingStatus: 503, missingError: "HOSTS_INGEST_TOKEN not configured", missingReason: "ingest token not configured", }, "claude-ingest": { token: CLAUDE_USAGE_INGEST_TOKEN, missingStatus: 503, missingError: "CLAUDE_USAGE_INGEST_TOKEN not configured", missingReason: "claude usage ingest token not configured", }, }); // A descriptor naming a kind the table does not know is a programming error: // refuse to start rather than discover it as a crash on the first request. for (const route of ROUTES) { if (!Object.hasOwn(TOKEN_KINDS, route.tokenKind)) { throw new Error(`route ${route.method} ${route.path || route.pattern} has unknown tokenKind`); } } // 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, looked up in TOKEN_KINDS; every kind goes through the same // tokenMatches() call below. function checkAuth(req, route) { const kind = TOKEN_KINDS[route.tokenKind]; const expected = kind.token; if (!expected) { return { status: kind.missingStatus, body: { error: kind.missingError }, reason: kind.missingReason }; } const header = req.headers["authorization"]; const ok = typeof header === "string" && header.startsWith("Bearer ") && tokenMatches(header.slice("Bearer ".length), 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) { sweepStaleTempFiles(); const server = http.createServer(handler); server.listen(PORT, () => { console.log(`vps-health-api listening on :${PORT}`); }); } module.exports = { handler, allowedSeverities, findLatestReportForAgent, tokenMatches, sanitizeSnapshot, sweepStaleTempFiles, HOST_ID_RE, HOST_ROUTE_RE, };