From 28dd759f988d8c2fc5ae9f956d41c3820a7e5ecd Mon Sep 17 00:00:00 2001 From: le king fu Date: Tue, 21 Apr 2026 21:38:20 -0400 Subject: [PATCH] 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) --- .env.example | 1 + CLAUDE.md | 28 +++++++++++++++++++++++++++ index.js | 53 +++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 CLAUDE.md diff --git a/.env.example b/.env.example index 1ec266f..628d8a9 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ PORT=3001 HEALTH_TOKEN=change-me-to-a-strong-secret +LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..523c17d --- /dev/null +++ b/CLAUDE.md @@ -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) diff --git a/index.js b/index.js index 1e9252b..aa5f09c 100644 --- a/index.js +++ b/index.js @@ -2,9 +2,14 @@ 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)."); @@ -22,13 +27,14 @@ function readProcStat() { } } -function getCpuPercent() { +async function getCpuPercent() { const t1 = readProcStat(); if (!t1) return 0; const { idle: idle1, total: total1 } = t1; - // Sample over 500ms - execSync("sleep 0.5"); + // 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; @@ -38,6 +44,24 @@ function getCpuPercent() { 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 @@ -54,12 +78,17 @@ function getDisk() { } } -function getHealth() { +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(), @@ -68,7 +97,7 @@ function getHealth() { model: cpus[0]?.model?.trim() || "unknown", cores: cpus.length, loadAvg: os.loadavg().map((l) => +l.toFixed(2)), - usagePercent: getCpuPercent(), + usagePercent: cpuUsagePercent, }, memory: { totalGB: +(totalMem / 1e9).toFixed(1), @@ -77,10 +106,11 @@ function getHealth() { usagePercent: Math.round((usedMem / totalMem) * 100), }, disk: getDisk(), + logto, }; } -const server = http.createServer((req, res) => { +const server = http.createServer(async (req, res) => { res.setHeader("Content-Type", "application/json"); const validRoutes = ["/health", "/defenseurs"]; @@ -116,9 +146,14 @@ const server = http.createServer((req, res) => { return; } - const data = getHealth(); - res.writeHead(200); - res.end(JSON.stringify(data)); + 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, () => { -- 2.45.2