vps-health-api/index.js
le king fu 28dd759f98 feat: add Logto healthcheck to /health endpoint
Fixes #1.

- New `logto: {status, responseTimeMs, error?}` field in /health response
- Configurable via LOGTO_HEALTH_URL env (default: auth.lacompagniemaximus.com
  OIDC discovery endpoint)
- 3s timeout via AbortController; /health stays HTTP 200 even if Logto is down
- getCpuPercent converted to async (setTimeout-based delay) so the 500ms CPU
  sample and the Logto fetch run concurrently via Promise.all; total latency
  stays max(500ms, <=3000ms) instead of the sum
- Commit project CLAUDE.md (previously untracked) with the new field documented

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:38:20 -04:00

161 lines
4.9 KiB
JavaScript

const http = require("node:http");
const os = require("node:os");
const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs");
const { setTimeout: delay } = require("node:timers/promises");
const PORT = parseInt(process.env.PORT || "3001", 10);
const TOKEN = process.env.HEALTH_TOKEN;
const LOGTO_HEALTH_URL =
process.env.LOGTO_HEALTH_URL ||
"https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration";
const LOGTO_TIMEOUT_MS = 3000;
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 };
}
}
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");
const validRoutes = ["/health", "/defenseurs"];
if (req.method !== "GET" || !validRoutes.includes(req.url)) {
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 (req.url === "/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;
}
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}`);
});