All checks were successful
CI / verification (pull_request) Successful in 36s
Track the PostgreSQL cluster backups (la-compagnie-maximus#220) the same
way as /claude-usage: freshness is decided by the server.
- GET /backups (read token) serves { vps, lenovo }: the status.json the
VPS script writes, rebuilt through a whitelist (closed error codes,
rotation block included), and the last workstation pull report. Each
block gets ageSeconds, stale and staleAfterSeconds from the server
(BACKUPS_STALE_SECONDS, 36 h): VPS on generatedAt, workstation on the
server-stamped receivedAt. A broken file gives a null block plus
vpsError/lenovoError, never a 500; no_data only when both are absent.
- POST /backups/pull: fourth token kind, backups-ingest, on
BACKUPS_INGEST_TOKEN (503 naming it when unset). Strict validation of
{ pulledAt, files, ok, error? }, server receivedAt, atomic write in
mode 600 through writeJsonAtomic(), now shared with /claude-usage.
Answers 204 and 413 like the two other ingest routes (the agent only
checks for a 2xx), where the issue said 200 and 400.
- backups.js joins the Dockerfile COPY line. Tests: backups.test.js
(101 cases), auth/writes pins extended, 6 socket cases in test-curl.sh.
Refs #58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1180 lines
43 KiB
JavaScript
1180 lines
43 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 { sanitizeBackupsStatus, validateBackupsPull, BACKUPS_MAX_BODY_BYTES } = require("./backups.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;
|
|
|
|
// --- PostgreSQL backups (GET /backups, POST /backups/pull) ------------------
|
|
|
|
// status.json is written by the VPS script (read-only mount); the pull report
|
|
// is written here, by POST /backups/pull, with its own token. The sanitizer and
|
|
// the error-code lists live in backups.js.
|
|
const BACKUPS_STATUS_PATH =
|
|
process.env.BACKUPS_STATUS_PATH || "/data/backups-status/status.json";
|
|
const BACKUPS_PULL_PATH = process.env.BACKUPS_PULL_PATH || "/data/backups-pull/pull.json";
|
|
const BACKUPS_INGEST_TOKEN = process.env.BACKUPS_INGEST_TOKEN;
|
|
|
|
// 36 h: the VPS runs daily at 03:00 UTC and the pull timer daily, so one missed
|
|
// run is tolerated and two are not.
|
|
const backupsStaleRaw = parseInt(process.env.BACKUPS_STALE_SECONDS || "", 10);
|
|
const BACKUPS_STALE_SECONDS =
|
|
Number.isFinite(backupsStaleRaw) && backupsStaleRaw > 0 ? backupsStaleRaw : 129600;
|
|
|
|
// 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 };
|
|
}
|
|
|
|
// --- Single-file snapshots (Claude usage, backups pull) ---------------------
|
|
|
|
// 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 reader sees the old file or the
|
|
// new one, never a truncated JSON. Temp name: `.<basename>.<pid>.<ms>.tmp`.
|
|
function writeJsonAtomic(filePath, record) {
|
|
const dir = path.dirname(filePath);
|
|
mkdirSync(dir, { recursive: true });
|
|
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
try {
|
|
writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
|
|
renameSync(tmpPath, filePath);
|
|
} 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 };
|
|
}
|
|
|
|
// --- Backups helpers --------------------------------------------------------
|
|
|
|
// Read a JSON file, telling "absent" (null) from "there but unusable"
|
|
// ({ error }): GET /backups reports the second as vpsError / lenovoError
|
|
// instead of hiding a broken file behind no_data. Error codes only, never the
|
|
// file content nor err.message (which would carry the path).
|
|
function readJsonFile(filePath, label) {
|
|
let raw;
|
|
try {
|
|
raw = readFileSync(filePath, "utf-8");
|
|
} catch (err) {
|
|
if (err.code === "ENOENT") return null;
|
|
console.error(`[backups] ${label} unreadable (${err.code || "error"})`);
|
|
return { error: "unreadable" };
|
|
}
|
|
try {
|
|
return { value: JSON.parse(raw) };
|
|
} catch {
|
|
console.error(`[backups] ${label} is not valid JSON`);
|
|
return { error: "invalid_json" };
|
|
}
|
|
}
|
|
|
|
// The VPS status, rebuilt through the whitelist. null when absent.
|
|
function readBackupsStatus() {
|
|
const read = readJsonFile(BACKUPS_STATUS_PATH, "status.json");
|
|
if (read === null || read.error) return read;
|
|
const result = sanitizeBackupsStatus(read.value);
|
|
if (result.error) {
|
|
console.error(`[backups] status.json rejected on read (${result.error})`);
|
|
return { error: result.error };
|
|
}
|
|
return { block: result.status, since: result.status.generatedAt };
|
|
}
|
|
|
|
// The last pull report. The file lives on a writable bind-mount: it is
|
|
// re-validated on the way out, receivedAt included, like the host snapshots.
|
|
function readBackupsPull() {
|
|
const read = readJsonFile(BACKUPS_PULL_PATH, "pull.json");
|
|
if (read === null || read.error) return read;
|
|
const result = validateBackupsPull(read.value);
|
|
const receivedAt = read.value?.receivedAt;
|
|
if (result.error || !isIsoTimestamp(receivedAt)) {
|
|
console.error("[backups] pull.json rejected on read");
|
|
return { error: "invalid_contract" };
|
|
}
|
|
return { block: { ...result.pull, receivedAt }, since: receivedAt };
|
|
}
|
|
|
|
// Staleness is decided here and only here, as for /claude-usage: the admin
|
|
// card and /warmup display `stale`, they do not recompute it.
|
|
function withFreshness(block, since, now) {
|
|
const ageSeconds = Math.max(0, Math.floor((now - new Date(since).getTime()) / 1000));
|
|
return {
|
|
...block,
|
|
ageSeconds,
|
|
stale: ageSeconds > BACKUPS_STALE_SECONDS,
|
|
staleAfterSeconds: BACKUPS_STALE_SECONDS,
|
|
};
|
|
}
|
|
|
|
// --- 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 {
|
|
writeJsonAtomic(CLAUDE_USAGE_PATH, 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 }));
|
|
}
|
|
|
|
// { vps, lenovo } with freshness, the VPS aged on generatedAt and the
|
|
// workstation on the server-stamped receivedAt (#58). A file that is there but
|
|
// unusable gives a null block plus its error code, never a 500: one broken
|
|
// side must not hide the other. no_data only when both files are absent.
|
|
function handleBackupsRead(req, res) {
|
|
const vps = readBackupsStatus();
|
|
const lenovo = readBackupsPull();
|
|
if (vps === null && lenovo === null) {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ status: "no_data" }));
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const body = {
|
|
vps: vps?.block ? withFreshness(vps.block, vps.since, now) : null,
|
|
lenovo: lenovo?.block ? withFreshness(lenovo.block, lenovo.since, now) : null,
|
|
};
|
|
if (vps?.error) body.vpsError = vps.error;
|
|
if (lenovo?.error) body.lenovoError = lenovo.error;
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
async function handleBackupsPullIngest(req, res) {
|
|
const raw = await readBody(req, res, BACKUPS_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 = validateBackupsPull(payload);
|
|
if (result.error) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: `Bad request: ${result.error}` }));
|
|
return;
|
|
}
|
|
|
|
// Server-stamped: pulledAt is the workstation's clock and stays informative,
|
|
// freshness is judged on receivedAt only. A client receivedAt never got
|
|
// past validateBackupsPull().
|
|
const record = { ...result.pull, receivedAt: new Date().toISOString() };
|
|
|
|
try {
|
|
writeJsonAtomic(BACKUPS_PULL_PATH, record);
|
|
} catch (err) {
|
|
console.error(`[backups] failed to persist pull report (${err.code || "error"})`);
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify({ error: "Internal error" }));
|
|
return;
|
|
}
|
|
res.removeHeader("Content-Type");
|
|
res.writeHead(204);
|
|
res.end();
|
|
}
|
|
|
|
// --- 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 },
|
|
{ method: "GET", path: "/backups", tokenKind: "read", handle: handleBackupsRead },
|
|
{ method: "POST", path: "/backups/pull", tokenKind: "backups-ingest", handle: handleBackupsPullIngest },
|
|
];
|
|
|
|
// 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",
|
|
},
|
|
"backups-ingest": {
|
|
token: BACKUPS_INGEST_TOKEN,
|
|
missingStatus: 503,
|
|
missingError: "BACKUPS_INGEST_TOKEN not configured",
|
|
missingReason: "backups 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,
|
|
};
|