feat: add Logto healthcheck to /health endpoint #2

Merged
maximus merged 1 commit from issue-1-logto-healthcheck into main 2026-04-22 01:56:23 +00:00
3 changed files with 73 additions and 9 deletions
Showing only changes of commit 28dd759f98 - Show all commits

View file

@ -1,2 +1,3 @@
PORT=3001 PORT=3001
HEALTH_TOKEN=change-me-to-a-strong-secret HEALTH_TOKEN=change-me-to-a-strong-secret
LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration

28
CLAUDE.md Normal file
View file

@ -0,0 +1,28 @@
# VPS Health API
API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
## Endpoints
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
- `GET /defenseurs` — contenu de status.json (rapports defenseurs)
## Auth
- Bearer token via env `HEALTH_TOKEN`
- Fail-closed : si `HEALTH_TOKEN` non configure, toutes les requetes sont refusees
## Config
- Port : `3001` (env `PORT`)
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
- Bind-mount : `/data/defenseurs/status.json` read-only
## Deploy
Coolify auto-rebuild depuis push Forgejo. Aucune action manuelle requise.
## Gotchas
- Pas d'Express — HTTP natif Node.js uniquement
- Le `status.json` est ecrit par le Sergent defenseurs, pas par cette API (read-only)

View file

@ -2,9 +2,14 @@ const http = require("node:http");
const os = require("node:os"); const os = require("node:os");
const { execSync } = require("node:child_process"); const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs"); const { readFileSync } = require("node:fs");
const { setTimeout: delay } = require("node:timers/promises");
const PORT = parseInt(process.env.PORT || "3001", 10); const PORT = parseInt(process.env.PORT || "3001", 10);
const TOKEN = process.env.HEALTH_TOKEN; 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) { if (!TOKEN) {
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed)."); console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
@ -22,13 +27,14 @@ function readProcStat() {
} }
} }
function getCpuPercent() { async function getCpuPercent() {
const t1 = readProcStat(); const t1 = readProcStat();
if (!t1) return 0; if (!t1) return 0;
const { idle: idle1, total: total1 } = t1; const { idle: idle1, total: total1 } = t1;
// Sample over 500ms // Sample over 500ms without blocking the event loop, so other async work
execSync("sleep 0.5"); // (e.g. the Logto healthcheck) can run concurrently.
await delay(500);
const t2 = readProcStat(); const t2 = readProcStat();
if (!t2) return 0; if (!t2) return 0;
@ -38,6 +44,24 @@ function getCpuPercent() {
return Math.round((1 - dIdle / dTotal) * 100); 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() { function getDisk() {
try { try {
// Alpine df doesn't support --output, use standard POSIX format // Alpine df doesn't support --output, use standard POSIX format
@ -54,12 +78,17 @@ function getDisk() {
} }
} }
function getHealth() { async function getHealth() {
const cpus = os.cpus(); const cpus = os.cpus();
const totalMem = os.totalmem(); const totalMem = os.totalmem();
const freeMem = os.freemem(); const freeMem = os.freemem();
const usedMem = totalMem - freeMem; const usedMem = totalMem - freeMem;
const [cpuUsagePercent, logto] = await Promise.all([
getCpuPercent(),
getLogtoHealth(),
]);
return { return {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
hostname: os.hostname(), hostname: os.hostname(),
@ -68,7 +97,7 @@ function getHealth() {
model: cpus[0]?.model?.trim() || "unknown", model: cpus[0]?.model?.trim() || "unknown",
cores: cpus.length, cores: cpus.length,
loadAvg: os.loadavg().map((l) => +l.toFixed(2)), loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
usagePercent: getCpuPercent(), usagePercent: cpuUsagePercent,
}, },
memory: { memory: {
totalGB: +(totalMem / 1e9).toFixed(1), totalGB: +(totalMem / 1e9).toFixed(1),
@ -77,10 +106,11 @@ function getHealth() {
usagePercent: Math.round((usedMem / totalMem) * 100), usagePercent: Math.round((usedMem / totalMem) * 100),
}, },
disk: getDisk(), disk: getDisk(),
logto,
}; };
} }
const server = http.createServer((req, res) => { const server = http.createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json"); res.setHeader("Content-Type", "application/json");
const validRoutes = ["/health", "/defenseurs"]; const validRoutes = ["/health", "/defenseurs"];
@ -116,9 +146,14 @@ const server = http.createServer((req, res) => {
return; return;
} }
const data = getHealth(); try {
res.writeHead(200); const data = await getHealth();
res.end(JSON.stringify(data)); 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, () => { server.listen(PORT, () => {