vps-health-api/CLAUDE.md
le king fu fedb1c81dc feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts)
Open the API's first write surface. Workstations push their CPU / memory /
disk snapshot, the admin dashboard reads it back with a server-computed
freshness.

Routing moves from an exact path list to a descriptor table, keeping ONE
authentication checkpoint:

  resolveRoute()  -> no match means an immediate 404, deny by default
  checkAuth()     -> the single gate; only the expected token varies per
                     descriptor (HEALTH_TOKEN for reads, HOSTS_INGEST_TOKEN
                     for ingestion)
  dispatch

The routing-before-auth ordering is preserved on purpose: an unknown path or
a wrong method still answers 404, never 401, exactly as before. The two
`404 (not 401)` tests added in #12 pin that ordering and still pass.

Security controls on the new write path:

- The route regex ^/hosts/([a-z0-9][a-z0-9-]{0,31})$, POST-only, IS the path
  traversal control. URL() normalises /hosts/../../etc/passwd to /etc/passwd
  and encoded traversals fail the match, so every hostile id lands on 404.
  403 is reserved for well-formed ids outside the allowlist.
- 401 wins over 403, so an unauthenticated caller cannot probe the allowlist.
- tokenMatches() compares SHA-256 digests with timingSafeEqual. Scoped to the
  ingestion path only; realigning HEALTH_TOKEN touches every read route in
  production and is tracked separately.
- Body capped at 4 KiB by counting received bytes, never Content-Length:
  a client can lie in the header and chunked encoding omits it. Past the cap,
  413 then req.destroy() once the response has flushed.
- The persisted object is rebuilt field by field from a whitelist — finite
  numbers, strings capped at 128 chars, everything else dropped — because the
  file is read back by GET /hosts and ends up in the admin React tree. The
  read path re-runs the same whitelist: the bind-mount is writable, so the
  file on disk earns no more trust than the payload did.
- Snapshots are written to a temp file in the same directory then renamed, so
  a concurrent GET /hosts can never observe a truncated JSON.
- HOSTS_ALLOWED_IDS entries are validated at startup with the same regex;
  rejects are logged and dropped rather than becoming file paths.
- Every 401/403 is logged with X-Real-IP, the host id and the reason. Log
  values are filtered to printable ASCII so a crafted header cannot forge
  extra log lines.
- receivedAt is stamped by the server on arrival; a client-supplied timestamp
  is discarded by the whitelist. online = ageSeconds <= HOSTS_STALE_SECONDS.

Runtime stays zero-dependency — node:crypto is a builtin and no new module
file was added, so the Dockerfile's explicit COPY list is unchanged.

Tests: 61 (39 existing untouched + 22 new). __tests__/hosts.test.js is smoke
coverage of the decisions that would be silent to regress; the exhaustive
matrix is issue #14.

Docs: .env.example gains HOSTS_DIR / HOSTS_ALLOWED_IDS / HOSTS_INGEST_TOKEN /
HOSTS_STALE_SECONDS; CLAUDE.md and README.md document the endpoints, the
routing/auth ordering and the config. The CLAUDE.md "read-only" gotcha is
corrected — the API now writes, and HOSTS_DIR must be writable by uid 1000.

Resolves #13
2026-08-16 11:49:36 -04:00

10 KiB

VPS Health API

API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.

Endpoints

  • GET /health — CPU, memoire, disque, uptime, logto ({status, responseTimeMs, error?})

  • GET /defenseurs — contenu de status.json (rapports defenseurs)

  • GET /reports/scans?date=YYYY-MM-DD — agrege les rapports defenseur-<agent>_<date>*.json du jour, format { date, count, reports: Report[] }. Filtre isScanReport (exclut defenseur-auto_*.json). Date validee par regex (path traversal bloque). Consommateur : defenseur-auto workstation cron (remplace le pre-rsync SSH). Exemple : curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/reports/scans?date=2026-05-07".

  • GET /defenseurs/findings?project=X — findings detailles du Defenseur correspondant. Query params : project=<name> (obligatoire, lookup via agents-map.json), category=<deps|secrets|code|acces|infra> (optionnel, exact match), severity=<CRITICAL|HIGH|MEDIUM|LOW|INFO> (optionnel, threshold inclusif vers le haut). Sans severity -> MEDIUM+HIGH+CRITICAL (cache LOW+INFO). severity=LOW -> LOW+MEDIUM+HIGH+CRITICAL (cache toujours INFO, asymetrie volontaire). severity=INFO -> INFO uniquement (opt-in explicite). Reponses : 200 { agent, project, timestamp, findings[] } si report present (sans champ status) ; 200 { findings: [], status: "no_data" } si pas de report ; 400 sans project ou param invalide ; 404 projet inconnu ; 500 si agents-map.json corrompu. Consommateurs : admin dashboard Vercel (drill-down), futur skill /analyse-vulnerabilite. Exemple : curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH".

  • GET /hosts — dernier snapshot de chaque poste de l'allowlist. Format { staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }. online = ageSeconds <= HOSTS_STALE_SECONDS (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en { id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true } sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel.

  • POST /hosts/<id>seule surface d'ecriture de l'API. Auth par HOSTS_INGEST_TOKEN (pas HEALTH_TOKEN). Corps = payload de GET /health moins logto et timestamp. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 <id> bien forme mais hors allowlist / 404 <id> hors format / 413 corps > 4 Kio (connexion coupee) / 503 HOSTS_INGEST_TOKEN non configure. Exemple : curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad".

Routage et auth

Ordre non negociable, fige par deux tests 404 (not 401) dans __tests__/auth.test.js :

  1. La requete est resolue en descripteur { method, path|pattern, tokenKind, handle } via la table ROUTES d'index.js. Aucun match -> 404 immediat (refus par defaut), avant toute verification de token.
  2. checkAuth()un seul point de controle, dont seul le token attendu varie (tokenKind: "read" -> HEALTH_TOKEN, "ingest" -> HOSTS_INGEST_TOKEN). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee.
  3. Dispatch vers le handler.

Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord.

  • Fail-closed : HEALTH_TOKEN absent -> 401 sur les lectures ; HOSTS_INGEST_TOKEN absent -> 503 sur l'ingestion
  • tokenMatches() (timingSafeEqual sur empreintes SHA-256) protege uniquement le chemin d'ingestion. Le realignement du HEALTH_TOKEN existant est suivi separement (issue #17) : il toucherait tous les chemins de lecture en production
  • 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist
  • Chaque rejet 401/403 est journalise ([auth] <code> <method> <url> ip=<X-Real-IP> id=<host> reason=<motif>), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log
  • Coolify : HEALTH_TOKEN et HOSTS_INGEST_TOKEN doivent etre is_runtime=true, is_buildtime=false. Buildtime fait fuiter le secret en clair dans application_deployment_queues.logs. Voir la-compagnie-maximus/docs/coolify-ops.md section "Secrets en buildtime".

Config

  • Port : 3001 (env PORT)
  • LOGTO_HEALTH_URL : URL du .well-known/openid-configuration (default auth.lacompagniemaximus.com)
  • REPORTS_DIR : dossier lu par /reports/scans et /defenseurs/findings (default /data/defenseurs/reports)
  • DEFENSEURS_AGENTS_MAP_PATH : snapshot project->agent ecrit par le Sergent (default /data/defenseurs/agents-map.json)
  • HOSTS_DIR : dossier ecrit par POST /hosts/<id>, un <id>.json par poste (default /data/hosts)
  • HOSTS_ALLOWED_IDS : allowlist separee par virgules (default thinkpad — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee
  • HOSTS_INGEST_TOKEN : token d'ingestion, distinct de HEALTH_TOKEN (pas de default — absent = 503)
  • HOSTS_STALE_SECONDS : age au-dela duquel un poste est reporte hors ligne (default 900)
  • Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) :
    • /data/defenseurs (host) -> /data/defenseurs : status.json + agents-map.json, ecrits directement la par le Sergent. /home/defenseur/defenseurs/status.json est un leurre obsolete lu par personne. Lecture seule pour cette API.
    • /home/defenseur/defenseurs/reports (host) -> /data/defenseurs/reports : rapports de scan (+ sous-dir archive/). Pose le 2026-07-15 (issue #10). Lecture seule pour cette API.
    • /data/hosts (host) -> /data/hosts : snapshots des postes. Lecture-ecriture — doit appartenir a l'uid 1000 (node, l'utilisateur du conteneur), sinon chaque ingestion repond 500.

Deploy

Pas d'auto-deploy : l'app Coolify n'a pas de Source Forgejo (source_id=null, migration trackee dans la-compagnie-maximus#133). Apres un merge sur main, trigger manuel :

curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
  "https://coolify.lacompagniemaximus.com/api/v1/deploy?uuid=u8000gsg044wsk0oo0w884ok&force=true"

(ou bouton Redeploy dans l'UI Coolify.)

Tests

  • npm test (vitest) — 61 cas
    • __tests__/findings.test.js/defenseurs/findings (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
    • __tests__/health.test.js — module metrics.js + /health (6 cas : exports, payload champ pour champ, garde de latence)
    • __tests__/auth.test.js — filet de securite auth sur les routes de lecture (19 cas : matrice route x mode d'echec, en-tetes malformes, fail-closed, et 2 cas 404 (not 401) qui figent l'ordre routage -> auth)
    • __tests__/hosts.test.js — smoke des endpoints postes (22 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, 413, neverSeen, fraicheur serveur, entree corrompue degradee). La matrice exhaustive est l'issue #14.
  • Runtime reste 0-dep ; vitest en devDep uniquement

Gotchas

  • Pas d'Express — HTTP natif Node.js uniquement
  • Le COPY du Dockerfile liste les fichiers un par un (package.json index.js metrics.js) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un MODULE_NOT_FOUND que les tests locaux ne voient pas.
  • getHealth() garde Promise.all([collectMetrics(), getLogtoHealth()]) : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de /health a ~3,5 s. Garde de non-regression : le test de latence dans __tests__/health.test.js.
  • L'API n'est plus read-only depuis l'issue #13. status.json, agents-map.json et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche POST /hosts/<id> ecrit dans HOSTS_DIR. Consequence : ce montage-la doit etre en lecture-ecriture pour l'uid 1000, contrairement aux montages /data/defenseurs.
  • L'ecriture des snapshots passe par un fichier temporaire dans le meme repertoire puis renameSyncrename est atomique dans un systeme de fichiers, donc un GET /hosts concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en writeFileSync direct.
  • L'objet persiste est reconstruit champ par champ depuis une liste blanche (nombres via Number.isFinite, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par GET /hosts et finit dans l'arbre React de l'admin : une cle __proto__ ou un hostname de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload.
  • Le plafond de corps a 4 Kio compte les octets recus, pas le Content-Length : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis req.destroy() une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code).
  • agents-map.json doit etre present sur le VPS avant le deploy : verifier via ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json' (PAS /home/defenseur/..., leurre obsolete). Sinon /defenseurs/findings retourne 500.
  • Coolify ignore silencieusement -v dans custom_docker_run_options — les volumes passent par les Persistent Storages (UI seulement). Details dans la-compagnie-maximus/docs/coolify-ops.md.
  • Severity threshold est asymetrique : ?severity=LOW retourne LOW+MEDIUM+HIGH+CRITICAL mais cache INFO. INFO est seulement accessible via ?severity=INFO explicite (cache le bruit par defaut).