vps-health-api/CLAUDE.md
le king fu 67d40c83c3
All checks were successful
CI / verification (pull_request) Successful in 27s
CI / verification (push) Successful in 28s
test(ci): pin the two ends of the port range and the end anchor (#46)
Non-blocking points of the second review of PR #47: refusing 65535, and
dropping the end anchor of the pattern, both left the suite green.

- Ports 1 and 65535 must be accepted. Given to both variables at once,
  so the run stops on the "must differ" refusal, which comes after the
  range check, and nothing listens on a port fixed in advance.
- "80abc" must be refused, on each variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:15:03 -04:00

27 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 (figee par __tests__/writes.test.js, sur les chemins nominaux et degrades qu'il exerce). 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 double dans __tests__/auth.test.js : un espion sur crypto.timingSafeEqual (pose avant un require frais, index.js destructurant l'import au chargement) exige que chaque requete compare deux empreintes de 32 octets, meme quand le token recu n'a pas la bonne longueur ; et des epingles sur le source de checkAuth() et de tokenMatches()
  • 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:ro : 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. Monte en lecture seule depuis le 2026-09-30 (issue #36).
    • /home/defenseur/defenseurs/reports (host) -> /data/defenseurs/reports:ro : rapports de scan (+ sous-dir archive/), montage imbrique dans le precedent. Pose le 2026-07-15 (issue #10), recree le 2026-09-30 en Volume Mount avec Source Path pour recevoir le :ro (#36). Monte en lecture seule.
    • /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.
    • Deux couches tiennent l'API hors des fichiers Defenseurs. (1) Le mode du montage : :ro est un suffixe de la destination dans Coolify, recopie tel quel dans le compose genere ('/data/defenseurs:/data/defenseurs:ro'). (2) Les droits Unix : le contenu appartient a defenseur:defenseur (uid et gid 1001), dossiers en 755/775, fichiers en 664, et le conteneur tourne en uid 1000 (node, gid 1000, sans groupe supplementaire), donc test -w y echoue meme sans :ro. Ce sont les bits de groupe qui donnent l'ecriture : cette couche tient parce que le groupe proprietaire n'est pas le gid 1000 (groupe de node dans l'image, et de ubuntu sur l'hote). Un chgrp ubuntu ou un chmod o+w la ferait tomber sans bruit. Le :ro ne vaut que pour la vue du conteneur : le Sergent ecrit cote hote comme avant.
    • Verification apres tout redeploy, par trois vues : docker inspect <conteneur> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} RW={{.RW}}{{println}}{{end}}' (RW=false sur les deux montages Defenseurs, RW=true sur /data/hosts) ; /proc/mounts dans le conteneur (vue du noyau) ; et le contenu des routes de lecture, pas seulement leur code, car un montage manquant repond 200 no_data.

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

  • CI : .forgejo/workflows/ci.yml (issue #24), sur toute PR (pas de filtre branches:, pour que les maillons d'une pile soient batis) et sur push vers main. Un seul job en container: node:22-alpine, la base du Dockerfile : npm ci, npm test, bash test-curl.sh, puis sh scripts/boot-from-copy.sh. Verdict a lire sur le statut de commit (GET /repos/maximus/vps-health-api/commits/<sha>/status), l'API Actions repondant 404 sur cette instance.
  • npm test (vitest) — 377 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 (67 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur Bearer autre qu'un espace), quasi-rates du token sur les 6 routes de lecture, fail-closed, 2 cas 404 (not 401) qui figent l'ordre routage -> auth, un espion sur timingSafeEqual qui exige deux empreintes de 32 octets a chaque requete, et des epingles sur le source : aucune egalite contre une chaine Bearer, un seul tokenMatches() et un seul return null dans checkAuth(), egalites limitees a une liste blanche, tokenMatches() qui hache les deux cotes sans test de longueur)
    • __tests__/hosts.test.js — matrice exhaustive des endpoints postes (173 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, loadAvg a exactement 3 elements, 413, neverSeen, fraicheur serveur sous horloge figee, degradation de lecture, balayage des .tmp orphelins au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre ^ de TEMP_FILE_RE)
    • __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)
    • __tests__/ci.test.js — epingles du workflow et des scripts de CI (47 cas : image du container: identique au FROM du Dockerfile, les quatre etapes presentes, aucun filtre branches: sous pull_request, push limite a main, ni defaults: ni working-directory sur une etape uses:, aucun continue-on-error, sous toute ecriture litterale (une etape rouge passerait au vert en silence ; detecteur sur le mot nu, sans ancrage ni deux-points exiges, lui-meme verifie sur la cle en bloc, en element de liste, en style flow, entre guillemets et via une ancre YAML ; seules les lignes de commentaire entieres en sont exemptees ; une cle ecrite avec une sequence d'echappement, "continue\u002don-error", passe encore : l'epingle lit du texte, pas du YAML), ce que le boot check suppose du Dockerfile : USER node, CMD en forme exec, le refus d'un port deja occupe (voisin HTTP reel, script lance en spawn asynchrone), aucune requete par un proxy de l'environnement (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par un curl nu, avec un no_proxy qui laisse la boucle locale hors liste : exporter NO_PROXY seul ne suffit pas), test-curl.sh lance en entier (#41 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes par TEST_CURL_PORT et TEST_CURL_NOINGEST_PORT : derriere le meme faux proxy avec FORCE_COLOR=3, avec BASE_URL et NOINGEST_URL pointes sur un port mort, et avec TOKEN et INGEST_TOKEN herites d'une meme valeur, rien n'atteint le proxy, les 45 cas passent, le nombre etant ecrit en dur dans le test, et les deux ports sont refuses des la premiere tentative a la sortie du script ; deux passages au meme instant sur quatre ports libres, 45 cas chacun ; les refus de port : valeur non numerique, port 0, port au-dela de 65535, nombre suivi de lettres (80abc), chacun sur chacune des deux variables, puis deux ports identiques, et les deux bornes acceptees, 1 et 65535, donnees aux deux variables a la fois pour ne rien faire ecouter sur un port fixe (atteindre le refus « must differ » montre que la valeur a passe le controle de plage), message sur stderr et code 1 avant tout lancement ; un voisin HTTP reel sur le port principal, puis sur le second : refus avant tout lancement, le voisin n'ayant vu que la sonde / ; un occupant non HTTP sur le port principal, qui ferme aussitot (code curl 52 ou 56) puis qui accepte sans repondre (28) : meme refus, la regle etant « tout code autre que 7 » ; un serveur vivant qui n'ecoute jamais : message d'echeance et code 1 apres les 5 s d'attente ; puis un faux node en tete de PATH qui sort en 1 pour le port principal, puis le second : message sur stderr, code 1 en moins de 3 s, aucun serveur laisse sur son port ; et deux epingles sur le source du script : defauts 3099 et 3098, seules occurrences de ces nombres hors commentaires, et URLs derivees des ports sans lecture de l'environnement. Plus aucun cas n'ecoute sur 3099 ni 3098 : les defauts ne sont exerces que par l'etape CI « Socket-level pass » et par un bash test-curl.sh a la main. Les ports viennent de freePorts(), qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message de require_free, sans cas lance contre un mauvais serveur), et des require en colonne 0 seulement, sans import(), dans chaque .js du COPY. Cette derniere epingle est une heuristique sur les appels litteraux, espace avant la parenthese comprise (require (, import (). Son detecteur, loadsLate(), est lui-meme verifie sur six lignes : require indente et import a toute colonne, avec ou sans espace, signales ; require de premier niveau et ligne de commentaire laisses passer. Trous connus, entre autres, laisses ouverts (#35) : une fonction ecrite entiere sur une ligne en colonne 0 (const lazy = () => require("x");), un require( renvoye a la ligne en colonne 0 dans une fonction (de meme forme que le } = require("node:fs"); legitime d'index.js), et toute indirection, comme un alias de require)
    • __tests__/writes.test.js — seule surface d'ecriture : HOSTS_DIR (11 cas : espions sur toutes les fonctions d'ecriture de node:fs, formes callback, Sync et fs.promises, et sur child_process, poses avant un require frais apres purge du cache de tous les modules runtime (un metrics.js reste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des .tmp ; les six routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ; POST /hosts/<id> n'ecrit que sous HOSTS_DIR ; chemins degrades : rejets 401/403/404, entrees 400/413, agents-map.json corrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (no_data, neverSeen) ; commandes shell figees sur head -1 /proc/stat et df -k /, /health servant de temoin ; chaque cas prouve que ses espions sont vivants ; tout appel est compte, sans attribution par pile (#40 : une fonction node:fs passee telle quelle a process.nextTick, a un ecouteur ou a un .then s'execute sans frame runtime sur sa pile, et un filtre par pile la laissait passer) ; les retouches de fixtures du cas degrade passent par les fonctions reelles capturees avant la pose des espions, jamais par un rmSync recursif, qui appelle en interne le fs.rmdirSync public, donc l'espion ; epingle sur le bloc require.main, ensemble exact des appels, puisqu'il ne tourne jamais dans le processus de test. Limites : un chemin qu'aucun cas n'emprunte, et une ecriture differee au-dela de 20 ms. Contrepartie assumee : une ecriture du lanceur de tests lui-meme pendant un cas le ferait rougir)
  • Runtime reste 0-dep ; vitest en devDep uniquement
  • sh scripts/boot-from-copy.sh — demarre le CMD depuis les seules sources du COPY, copiees dans un dossier vide, et exige 401 sans token, 200 avec, et des metriques disque/memoire non nulles. Tient lieu de docker build en CI : un vrai build demanderait le socket Docker dans le runner d'instance, donc root sur le VPS de prod pour tout workflow de tout depot. Il n'accepte qu'une ligne COPY en forme shell sans option, et un CMD en forme exec ; toute autre forme echoue bruyamment plutot que de verifier du vide. Port 3097 par defaut (BOOT_PORT pour en changer) ; il refuse de demarrer si le port est deja pris (tout code de sortie curl autre que 7, « connexion refusee »), sans quoi ses assertions pourraient interroger un voisin. Il exporte NO_PROXY='*' et no_proxy='*' en tete : chaque curl vise la boucle locale, et un http_proxy de l'environnement repondrait a la place du port comme du serveur demarre. Les deux graphies, car curl lit d'abord la minuscule : un no_proxy herite l'emporterait sur un NO_PROXY exporte seul. Il n'appelle que /health : il ne prouve que les modules charges au demarrage, d'ou l'epingle « require en colonne 0 » dans ci.test.js.
  • 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. Les deux ports se changent par TEST_CURL_PORT et TEST_CURL_NOINGEST_PORT (#46), ce qui laisse tourner deux passages cote a cote ; une valeur hors 1-65535 ou deux ports identiques sont refuses avec un message, avant tout lancement. Des noms propres au script plutot que PORT, qu'un shell de dev exporte souvent deja. BASE_URL et NOINGEST_URL sont derives des ports et ne se lisent plus dans l'environnement : surcharges seuls, ils envoyaient les curl ailleurs que sur les serveurs demarres. TOKEN et INGEST_TOKEN sont fixes pour la meme raison : deux valeurs egales heritees cassaient les cas de cloisonnement. Quatre protections contre l'environnement (#41) : NO_PROXY='*' et no_proxy='*' exportes, comme dans boot-from-copy.sh (sans eux, derriere un proxy mort, le script sortait en code 7 apres 11 s sans une ligne) ; unset FORCE_COLOR, car sous cette variable node -e 'console.log(3)' colore le nombre meme dans un tube et 8 cas echouent ; require_free, qui exige avant tout lancement que les deux ports refusent la connexion (code curl 7, meme regle que boot-from-copy.sh) : c'est lui qui ecarte un voisin deja en place, car le serveur du script ne meurt de son EADDRINUSE qu'apres que la boucle d'attente a eu le temps de joindre le voisin ; et wait_for, qui arrete le script avec un message si le serveur meurt ou ne repond pas dans les 5 s (borne par horloge), en verifiant le processus (kill -0) avant et apres la sonde. Le trap EXIT protege chaque kill par || true (sous set -e, un kill en echec sur le premier serveur deja mort terminait le trap et laissait le second tourner sur son port) et finit par un wait, pour qu'un passage lance juste apres trouve les ports libres. Ce wait n'est epingle que par une tentative de connexion unique a la sortie du script : sans lui, le cas rougit le plus souvent, pas a chaque fois (4 passages sur 5 mesures). Non epingles : l'export de la majuscule NO_PROXY, que curl ne lit qu'apres la minuscule, et le second kill -0 de wait_for, qui ne couvre que la fenetre residuelle entre require_free et le listen du serveur.

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 npm test ne voit pas (seul scripts/boot-from-copy.sh, en CI, le rattrape). Un require place dans un handler de route echapperait au boot check (qui n'appelle que /health) : garder tous les require au niveau module, une epingle de ci.test.js l'exige. 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, alors que les deux montages Defenseurs sont en :ro (#36). Une ecriture ajoutee un jour sous REPORTS_DIR ou /data/defenseurs passerait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500 EROFS : __tests__/writes.test.js exige que toute ecriture par node:fs vise HOSTS_DIR, et que les seules commandes shell soient les deux lectures de metrics.js, sur chaque chemin qu'il exerce (demarrage, lectures et ingestion nominales, rejets, entrees invalides, fichier corrompu, fichiers absents). Un nouveau chemin qui ecrit doit y entrer comme cas.
  • 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. Un processus tue entre les deux laisse un .<id>.<pid>.<ms>.tmp : sweepStaleTempFiles() les supprime au demarrage, avant listen, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (lstat) ; changer le nom du fichier temporaire impose de changer TEMP_FILE_RE.
  • 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. loadAvg doit avoir exactement 3 nombres (la carte admin les joint par « / ») : un fichier ancien hors format degrade en neverSeen. Les chaines sont tronquees, pas filtrees (caracteres de controle conserves) : la surete repose sur l'echappement du consommateur, React aujourd'hui.
  • 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). Lecture seule : suffixer la destination par :ro, que Coolify recopie tel quel dans le compose (verifie sur beta.463, #36). Un Directory Mount ne se modifie pas dans l'UI : sauvegarder le dossier hote, supprimer le montage sans cocher l'effacement du dossier sur le serveur, puis le recreer en Volume Mount avec Source Path (sans Source Path, c'est un volume nomme qui ne lit pas le dossier hote). Sous un parent en :ro, le point de montage imbrique doit deja exister cote hote : /data/defenseurs/reports, dossier vide root en 755, est a garder, car Docker ne peut pas le creer dans un parent en lecture seule et le conteneur ne demarrerait pas. Ne jamais monter les fichiers Defenseurs un par un : le Sergent ecrit par rename atomique, un bind de fichier resterait accroche a l'ancien inode. 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).