Replaces the SSH/rsync canal between Max's workstation cron and the VPS
for fetching defenseur scan reports. The defenseur-auto orchestrator now
pulls reports/defenseur-X_<date>*.json over HTTPS, reusing HEALTH_TOKEN.
The handler mirrors the style of index.js (HTTP native, no framework),
includes the same isScanReport guard as defenseurs/src/report.ts (filters
out defenseur-auto_*.json run reports), and validates the date param
against /^\d{4}-\d{2}-\d{2}$/ to short-circuit path traversal before any
filesystem access.
Validated by test-curl.sh — 11 cases covering auth, validation, date
filter, isScanReport filter, sort order, GET-only and 404 paths.
Spike: ~/claude-code/.spikes/archived/endpoint-reports-sur-vps-health-api-pour/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
234 lines
7.6 KiB
JavaScript
234 lines
7.6 KiB
JavaScript
const http = require("node:http");
|
|
const os = require("node:os");
|
|
const { execSync } = require("node:child_process");
|
|
const { readFileSync, readdirSync, existsSync } = require("node:fs");
|
|
const path = require("node:path");
|
|
const { setTimeout: delay } = require("node:timers/promises");
|
|
|
|
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 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}$/;
|
|
|
|
if (!TOKEN) {
|
|
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
|
|
}
|
|
|
|
function readProcStat() {
|
|
try {
|
|
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
|
|
const parts = line.split(/\s+/).slice(1).map(Number);
|
|
const idle = parts[3] + parts[4];
|
|
const total = parts.reduce((a, b) => a + b, 0);
|
|
return { idle, total };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getCpuPercent() {
|
|
const t1 = readProcStat();
|
|
if (!t1) return 0;
|
|
const { idle: idle1, total: total1 } = t1;
|
|
|
|
// Sample over 500ms without blocking the event loop, so other async work
|
|
// (e.g. the Logto healthcheck) can run concurrently.
|
|
await delay(500);
|
|
|
|
const t2 = readProcStat();
|
|
if (!t2) return 0;
|
|
const dIdle = t2.idle - idle1;
|
|
const dTotal = t2.total - total1;
|
|
if (dTotal === 0) return 0;
|
|
return Math.round((1 - dIdle / dTotal) * 100);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function getDisk() {
|
|
try {
|
|
// Alpine df doesn't support --output, use standard POSIX format
|
|
const out = execSync("df -k /", { encoding: "utf-8" });
|
|
const parts = out.trim().split("\n")[1].trim().split(/\s+/);
|
|
// df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted
|
|
const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1);
|
|
const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1);
|
|
const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1);
|
|
const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0;
|
|
return { totalGB, usedGB, freeGB, usagePercent };
|
|
} catch {
|
|
return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 };
|
|
}
|
|
}
|
|
|
|
// 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 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).
|
|
function readScanReportsForDate(date) {
|
|
const out = [];
|
|
if (!existsSync(REPORTS_DIR)) return out;
|
|
|
|
const files = readdirSync(REPORTS_DIR).filter(
|
|
(f) => f.startsWith("defenseur-") && f.includes(`_${date}`) && f.endsWith(".json"),
|
|
);
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const raw = readFileSync(path.join(REPORTS_DIR, file), "utf-8");
|
|
const parsed = JSON.parse(raw);
|
|
if (!isScanReport(parsed)) continue;
|
|
if (!parsed.timestamp.startsWith(date)) continue;
|
|
out.push(parsed);
|
|
} catch (err) {
|
|
console.error(`[reports/scans] failed to parse ${file}:`, err.message);
|
|
}
|
|
}
|
|
|
|
// Stable sort by timestamp asc — same convention as readReports() in
|
|
// defenseurs/src/report.ts.
|
|
out.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
return out;
|
|
}
|
|
|
|
async function getHealth() {
|
|
const cpus = os.cpus();
|
|
const totalMem = os.totalmem();
|
|
const freeMem = os.freemem();
|
|
const usedMem = totalMem - freeMem;
|
|
|
|
const [cpuUsagePercent, logto] = await Promise.all([
|
|
getCpuPercent(),
|
|
getLogtoHealth(),
|
|
]);
|
|
|
|
return {
|
|
timestamp: new Date().toISOString(),
|
|
hostname: os.hostname(),
|
|
uptime: Math.floor(os.uptime()),
|
|
cpu: {
|
|
model: cpus[0]?.model?.trim() || "unknown",
|
|
cores: cpus.length,
|
|
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
|
|
usagePercent: cpuUsagePercent,
|
|
},
|
|
memory: {
|
|
totalGB: +(totalMem / 1e9).toFixed(1),
|
|
usedGB: +(usedMem / 1e9).toFixed(1),
|
|
freeGB: +(freeMem / 1e9).toFixed(1),
|
|
usagePercent: Math.round((usedMem / totalMem) * 100),
|
|
},
|
|
disk: getDisk(),
|
|
logto,
|
|
};
|
|
}
|
|
|
|
const server = http.createServer(async (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;
|
|
|
|
const validRoutes = ["/health", "/defenseurs", "/reports/scans"];
|
|
if (req.method !== "GET" || !validRoutes.includes(pathname)) {
|
|
res.writeHead(404);
|
|
res.end(JSON.stringify({ error: "Not found" }));
|
|
return;
|
|
}
|
|
|
|
if (!TOKEN) {
|
|
res.writeHead(401);
|
|
res.end(JSON.stringify({ error: "HEALTH_TOKEN not configured" }));
|
|
return;
|
|
}
|
|
|
|
const auth = req.headers["authorization"];
|
|
if (auth !== `Bearer ${TOKEN}`) {
|
|
res.writeHead(401);
|
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/defenseurs") {
|
|
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
|
|
try {
|
|
const status = readFileSync(statusPath, "utf-8");
|
|
res.writeHead(200);
|
|
res.end(status);
|
|
} catch {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ status: "no_data" }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (pathname === "/reports/scans") {
|
|
const date = 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 }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
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 }));
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`vps-health-api listening on :${PORT}`);
|
|
});
|