vps-health-api/CLAUDE.md
le king fu f70a7e928f fix(auth): compare HEALTH_TOKEN in constant time via tokenMatches
The read path still compared the bearer with `header === \`Bearer ${token}\``,
which stops at the first differing byte and leaks the token's length and
prefix through response time. checkAuth() now has a single comparison for
both token kinds: `Bearer ` scheme check, then tokenMatches() (SHA-256
digests, then timingSafeEqual, so lengths are always equal). Only the
expected token varies.

Semantics are unchanged: exact match, case-sensitive scheme. Every existing
auth test passes untouched.

Tests: near-miss tokens (prefix, suffix, double space) on all six read
routes, an empty-token assertion on the helper, and a source pin that
forbids direct equality against a Bearer string and requires exactly one
tokenMatches() call in checkAuth(). A timing test would be flaky, so the pin
is the regression guard: three mutations back to direct comparison each
turn it red.

Fixes #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:06:04 -04:00

13 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 /defenseurs/state — contenu brut de findings-state.json (etat par finding du Sergent : firstSeen, unverified, historique 30 jours). Meme contrat que /defenseurs via le helper partage serveJsonFile() : 200 contenu brut, 200 {status:"no_data"} si absent ou illisible. Ni parse ni controle de fraicheur ici (le Sergent ecrit en atomique et isole un etat corrompu en .corrupt-* ; la peremption est jugee cote dashboard). Consommateur : dashboard admin Vercel (drill-down, en parallele de /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, longueurs egalisees par le hachage) compare les deux tokens depuis l'issue #17 : checkAuth() n'a qu'une expression de comparaison, seul le token attendu varie. Ne jamais revenir a une egalite de chaine (header === `Bearer ${x}`) : elle s'arrete au premier octet different et fuit longueur et prefixe par le temps de reponse. Un test de timing serait instable ; la garde est une epingle sur le source dans __tests__/auth.test.js
  • 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_STATUS_PATH : fichier servi par /defenseurs (default /data/defenseurs/status.json)
  • DEFENSEURS_FINDINGS_STATE_PATH : fichier servi par /defenseurs/state (default /data/defenseurs/findings-state.json)
  • 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 + findings-state.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) — 284 cas
    • __tests__/findings.test.js — /defenseurs/findings (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
    • __tests__/state.test.js — /defenseurs/state + non-regression /defenseurs apres extraction de serveJsonFile() (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST)
    • __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 (50 cas : matrice route x mode d'echec, en-tetes malformes, quasi-rates du token sur les 6 routes de lecture, fail-closed, 2 cas 404 (not 401) qui figent l'ordre routage -> auth, et une epingle sur le source qui interdit toute egalite directe contre une chaine Bearer et exige un seul appel a tokenMatches() dans checkAuth())
    • __tests__/hosts.test.js — matrice exhaustive des endpoints postes (155 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 sous horloge figee, degradation de lecture)
    • __tests__/agent.test.js — agent poste (50 cas : contrat de payload verifie contre sanitizeSnapshot(), une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et le token absent de toute sortie d'echec, fragments de 5 caracteres compris)
  • Runtime reste 0-dep ; vitest en devDep uniquement
  • bash test-curl.sh — passe au niveau socket, complementaire et non redondante (45 cas). Vitest couvre la logique ; ce script couvre ce que seuls un vrai client et un vrai TCP peuvent montrer : le 413 rendu avant que la connexion soit coupee, les deux tokens qui se refusent mutuellement leurs routes, le fichier servi octet pour octet par /defenseurs/state, et le 503 d'une instance demarree sans HOSTS_INGEST_TOKEN (elle tourne sur un second port, le temps du script). Il boote ses propres serveurs sur 3099 et 3098 — aucun service exterieur requis.

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. Corollaire : agent/ n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir agent/README.md), et un test epingle que le COPY ne le mentionne jamais.
  • agent/ tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans logger -t host-agent : tout ce qu'il imprime finit dans /var/log/syslog et journald pour de bon. Le chemin d'erreur ne rend donc que err.code et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de set -x dans run-push.sh : le shell echoerait le token en sourcant le fichier d'env.
  • 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 renameSync — rename 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).