vps-health-api/CLAUDE.md
le king fu e24f5b6f00 feat(agent): push workstation metrics to /hosts/<id> from cron
Zero-dependency local agent: collect one snapshot through the shared
metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five
minutes. Nothing is queued and nothing is replayed — a heartbeat from five
minutes ago describes a machine that no longer exists, so a failed push is
logged and dropped rather than spooled.

run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to
start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never
enables shell tracing: the script is meant to be piped into `logger`, where
`set -x` would echo the ingest token into /var/log/syslog and journald for
good. Same reason the failure path reports only err.code and the HTTP status —
never an error object, request options, headers, or a response body. The cost
is accepted: a 400 says the payload was rejected, not why.

50 tests, including two that spawn the real process against a failing server
and scan its actual stdout and stderr for any five-character fragment of the
token. The payload is pinned against the server's own sanitizeSnapshot(), and
one case pushes a real collectMetrics() snapshot through the real ingestion
handler, so a drift between agent and server turns a test red instead of
producing a 400 at 3 a.m. on the ThinkPad.

agent/ stays out of the Docker image: the COPY line is untouched, and a test
pins that it copies files one by one and never names the directory.

Installing on a workstation — frozen copy, env file, crontab line, dry run,
and why two machines must never share a HOST_ID — is documented in
agent/README.md. The install itself belongs to the commissioning issue.

Resolves #15
2026-08-16 12:21:59 -04:00

11 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) — 244 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 — 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

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 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).