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}`); });