Review of PR #57, suggestions 1 and 2: - the excluded open day is now the most recent date of the snapshot, not its last element, so the rule no longer depends on the order the days were sent in (the sanitizer does not sort them); - a day dated past the server's UTC reception day is never archived, and GET /claude-usage/history no longer takes a month file dated past the current month as its default end. A skewed workstation clock could otherwise open history/2099-12.json and leave the default range empty. Refs #55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1035 lines
37 KiB
JavaScript
1035 lines
37 KiB
JavaScript
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, isIsoTimestamp, CLAUDE_USAGE_MAX_BODY_BYTES } = require("./claude-usage.js");
|
|
const {
|
|
historyDir,
|
|
closedDays,
|
|
archiveDays,
|
|
lastArchivedMonth,
|
|
parseRange,
|
|
readHistory,
|
|
} = require("./claude-usage-history.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");
|
|
|
|
// --- 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";
|
|
|
|
// Closed days kept past the 30-day window (#55): one file per month, on the
|
|
// same mount as the snapshot. Layout and merge rules in claude-usage-history.js.
|
|
const CLAUDE_USAGE_HISTORY_DIR = historyDir(CLAUDE_USAGE_PATH);
|
|
|
|
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-<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, 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 `.<id>.<pid>.<ms>.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 <id>.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 (!isIsoTimestamp(receivedAt)) {
|
|
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 (!isIsoTimestamp(receivedAt)) {
|
|
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=<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, 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);
|
|
} catch (err) {
|
|
console.error("[claude-usage] failed to persist snapshot:", err.message);
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify({ error: "Internal error" }));
|
|
return;
|
|
}
|
|
|
|
// The snapshot first, the archive second: an archive failure never costs
|
|
// the main data, and never changes the 204.
|
|
archiveClosedDays(record.days || [], record.receivedAt.slice(0, 10));
|
|
res.removeHeader("Content-Type");
|
|
res.writeHead(204);
|
|
res.end();
|
|
}
|
|
|
|
// Every day of the snapshot except the most recent one (today, still open)
|
|
// and any dated past the reception day, the latest snapshot winning (#55).
|
|
// Months and error codes only in the log, never a day's content.
|
|
function archiveClosedDays(days, receivedDay) {
|
|
try {
|
|
const report = archiveDays(CLAUDE_USAGE_HISTORY_DIR, closedDays(days, receivedDay));
|
|
for (const { month, code } of report.skipped) {
|
|
console.error(`[claude-usage] history ${month} skipped: unreadable file left untouched (${code})`);
|
|
}
|
|
for (const { month, code } of report.failed) {
|
|
console.error(`[claude-usage] history ${month} not written (${code})`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[claude-usage] history not written (${err.code || "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,
|
|
}));
|
|
}
|
|
|
|
// Archived days, `from` to `to` months inclusive (#55). Defaults and bounds in
|
|
// parseRange(); an unreadable month is left out and logged, never fatal.
|
|
function handleClaudeUsageHistory(req, res, ctx) {
|
|
const range = parseRange(
|
|
ctx.parsedUrl.searchParams.get("from"),
|
|
ctx.parsedUrl.searchParams.get("to"),
|
|
{
|
|
lastArchived: lastArchivedMonth(CLAUDE_USAGE_HISTORY_DIR),
|
|
currentMonth: new Date(Date.now()).toISOString().slice(0, 7),
|
|
},
|
|
);
|
|
if (range.error) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: `Bad request: ${range.error}` }));
|
|
return;
|
|
}
|
|
|
|
const { days, unreadable } = readHistory(CLAUDE_USAGE_HISTORY_DIR, range.from, range.to);
|
|
for (const { month, code } of unreadable) {
|
|
console.error(`[claude-usage] history ${month} unreadable, left out (${code})`);
|
|
}
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ from: range.from, to: range.to, days }));
|
|
}
|
|
|
|
// --- 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: "GET", path: "/claude-usage/history", tokenKind: "read", handle: handleClaudeUsageHistory },
|
|
{ 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,
|
|
};
|