Review of PR #57, suggestions 1 and 2: - the excluded open day is now the most recent date of the snapshot, not its last element, so the rule no longer depends on the order the days were sent in (the sanitizer does not sort them); - a day dated past the server's UTC reception day is never archived, and GET /claude-usage/history no longer takes a month file dated past the current month as its default end. A skewed workstation clock could otherwise open history/2099-12.json and leave the default range empty. Refs #55 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41 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 defindings-state.json(etat par finding du Sergent :firstSeen,unverified, historique 30 jours). Meme contrat que/defenseursvia le helper partageserveJsonFile(): 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 rapportsdefenseur-<agent>_<date>*.jsondu jour, format{ date, count, reports: Report[] }. FiltreisScanReport(exclutdefenseur-auto_*.json). Date validee par regex (path traversal bloque). Consommateur :defenseur-autoworkstation 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 viaagents-map.json),category=<deps|secrets|code|acces|infra>(optionnel, exact match),severity=<CRITICAL|HIGH|MEDIUM|LOW|INFO>(optionnel, threshold inclusif vers le haut). Sansseverity-> 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 champstatus) ; 200{ findings: [], status: "no_data" }si pas de report ; 400 sans project ou param invalide ; 404 projet inconnu ; 500 siagents-map.jsoncorrompu. 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>— premiere des deux surfaces d'ecriture de l'API (figees par__tests__/writes.test.js, sur les chemins nominaux et degrades qu'il exerce). Auth parHOSTS_INGEST_TOKEN(pasHEALTH_TOKEN). Corps = payload deGET /healthmoinslogtoettimestamp. 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) / 503HOSTS_INGEST_TOKENnon 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". -
POST /claude-usage— seconde surface d'ecriture (issue #48) : snapshot de consommation Claude pousse par l'agent du ThinkPad, contratschemaVersion: 1partage avec le dashboard (la-compagnie-maximus,spec-plan-suivi-claude.md). Auth parCLAUDE_USAGE_INGEST_TOKEN, son propre token : niHOSTS_INGEST_TOKENniHEALTH_TOKENn'y passent, et lui n'ouvre niPOST /hosts/<id>ni aucune lecture. Le corps passe parsanitizeClaudeUsage(), module purclaude-usage.js(l'agent teste son snapshot contre le vrai sanitizer) : reconstruction par liste blanche, champ inconnu jete, valeur invalide jetee (cle omise), jamais forcee — nombresNumber.isSafeInteger>= 0,percent0-100,limits.statusdans l'enumok|token_expired|http_error|unavailable|disabled,limits.detail^[a-z0-9_]{1,32}$(code, jamais texte libre : un corps de reponse amont ne doit pas voyager jusqu'au dashboard), dates ISO completes avec decalage,datede jour^\d{4}-\d{2}-\d{2}$et jour reel, cles demodels^[a-z0-9][a-z0-9.\-]{0,63}$, autres chaines tronquees a 128. Listes bornees :days<= 31 (au-dela, les 31 plus recents, le contrat les ordonnant du plus ancien a aujourd'hui),models<= 12 par jour,limits.items<= 12,projects<= 9 (au-dela, les premiers). Seuls deux cas refusent le corps entier : pas un objet JSON, ouschemaVersiondifferent de 1. Codes : 204 / 400 JSON illisible, pas un objet ouschemaVersion!= 1 / 401 / 413 corps > 64 Kio (65 536 octets recus) / 503CLAUDE_USAGE_INGEST_TOKENnon configure.receivedAtpose par le serveur, ecriture atomique (temporaire.<nom>.<pid>.<ms>.tmpdans le meme dossier, mode 600,renameSync), patron dewriteHostSnapshot. Archive (#55) : une fois le snapshot ecrit, tous ses jours sauf la date la plus recente (aujourd'hui, encore ouvert, quel que soit l'ordre d'envoi) et sauf toute date posterieure au jour UTC de reception (horloge du poste dereglee : elle ouvrirait un fichier mensuel futur) sont fusionnes dans<dossier de CLAUDE_USAGE_PATH>/history/YYYY-MM.json({ schemaVersion: 1, month, days: [{ date, models }] }, jours tries), moduleclaude-usage-history.js. Regle : le dernier snapshot gagne, un jour sorti de la fenetre garde la valeur que l'agent a figee a J+2 ; conservation illimitee. Un ou deux fichiers mensuels par POST, chacun en ecriture atomique (temporaire.YYYY-MM.json.<pid>.<ms>.tmp, mode 600,renameSync). Un echec d'archivage ne change pas le 204 : il est journalise ([claude-usage] history <mois> ..., mois et code d'erreur seulement, jamais le contenu). Un fichier mensuel illisible ou hors format n'est jamais ecrase (il garderait des jours sortis de la fenetre) : ce mois est saute et journalise, les autres s'ecrivent. Un snapshot d'un seul jour n'archive rien et ne cree pas le dossier. -
GET /claude-usage—{ staleAfterSeconds, receivedAt, ageSeconds, stale, snapshot }, ou 200{ status: "no_data" }si le fichier est absent ou illisible (corrompu, hors contrat, sansreceivedAten date ISO complete avec decalage, meme regle queGET /hostsdepuis #54). Le fichier est re-sanitise a la lecture (sanitizeClaudeUsageest idempotent).stale = ageSeconds > CLAUDE_USAGE_STALE_SECONDS, decide par le serveur seul : le dashboard ne le recalcule pas. Consommateur : dashboard admin Vercel. -
GET /claude-usage/history?from=YYYY-MM&to=YYYY-MM— jours archives (#55), token de lecture.{ from, to, days }, jours tries du plus ancien au plus recent. Les deux parametres sont optionnels :tovaut par defaut le mois du dernier fichier archive (le mois courant UTC si rien n'est archive ou si ce fichier est date apres le mois courant, oufroms'il est plus tard),fromvauttomoins 11 mois (12 mois, bornes comprises). 400 si un format est invalide (mois01-12, chaine vide comprise), sifrom > to, ou au-dela de 36 mois. Un mois absent est vide ; un mois illisible est omis et journalise ; chaque fichier est re-sanitise a la lecture par le nettoyeur de jour du snapshot (sanitizeDay, exporte parclaude-usage.js), et un jour date hors de son mois est jete. Rattrapage ponctuel des jours deja sortis de la fenetre :agent/claude-usage/backfill-history.jssur le poste, puisdocker exec -i <conteneur> node claude-usage-history.js --import - < fichier, ou une date deja archivee gagne (procedure dansagent/README.md).
Routage et auth
Ordre non negociable, fige par deux tests 404 (not 401) dans __tests__/auth.test.js :
- La requete est resolue en descripteur
{ method, path|pattern, tokenKind, handle }via la tableROUTESd'index.js. Aucun match -> 404 immediat (refus par defaut), avant toute verification de token. checkAuth()— un seul point de controle, dont seul le token attendu varie, lu dans la tableTOKEN_KINDS(issue #48) :"read"->HEALTH_TOKEN,"ingest"->HOSTS_INGEST_TOKEN,"claude-ingest"->CLAUDE_USAGE_INGEST_TOKEN. Chaque entree porte aussi sa reponse quand le token manque. Un descripteur deROUTESqui nomme untokenKindabsent de la table fait echouer le chargement du module (refus de demarrer plutot qu'un crash a la premiere requete). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee. Un nouveau token = une entree de la table, jamais unifdanscheckAuth()(l'epingle deauth.test.jsn'y admet plus quetypeof header === "string"comme egalite).- 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_TOKENabsent -> 401 sur les lectures ; chaque token d'ingestion absent -> 503 sur sa seule route, avec un message qui nomme sa propre variable (HOSTS_INGEST_TOKEN not configured,CLAUDE_USAGE_INGEST_TOKEN not configured) tokenMatches()(timingSafeEqualsur empreintes SHA-256, longueurs egalisees par le hachage) compare tous les 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 surcrypto.timingSafeEqual(pose avant unrequirefrais,index.jsdestructurant 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 decheckAuth()et detokenMatches()- 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_TOKENetHOSTS_INGEST_TOKENdoivent etreis_runtime=true, is_buildtime=false. Buildtime fait fuiter le secret en clair dansapplication_deployment_queues.logs. Voirla-compagnie-maximus/docs/coolify-ops.mdsection "Secrets en buildtime".
Config
- Port :
3001(envPORT) LOGTO_HEALTH_URL: URL du.well-known/openid-configuration(default auth.lacompagniemaximus.com)REPORTS_DIR: dossier lu par/reports/scanset/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 parPOST /hosts/<id>, un<id>.jsonpar poste (default/data/hosts)HOSTS_ALLOWED_IDS: allowlist separee par virgules (defaultthinkpad— 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 ecarteeHOSTS_INGEST_TOKEN: token d'ingestion, distinct deHEALTH_TOKEN(pas de default — absent = 503)HOSTS_STALE_SECONDS: age au-dela duquel un poste est reporte hors ligne (default 900)CLAUDE_USAGE_INGEST_TOKEN: token dePOST /claude-usage, distinct des deux autres (pas de default — absent = 503 nommant la variable). Meme regle Coolify que les autres tokens :is_runtime=true, is_buildtime=falseCLAUDE_USAGE_PATH: fichier ecrit parPOST /claude-usageet lu parGET /claude-usage(default/data/claude-usage/claude-usage.json)CLAUDE_USAGE_STALE_SECONDS: age au-dela duquelGET /claude-usagerepondstale: true(default 2700, soit 45 min ; une valeur non numerique ou <= 0 retombe sur le default)- 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.jsonest 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-dirarchive/), 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./data/claude-usage(host) ->/data/claude-usage: snapshot de consommation Claude (issue #48). Lecture-ecriture, proprietaire uid 1000, meme regle que/data/hosts(sinon chaquePOST /claude-usagerepond 500). Monter le dossier, jamais le fichier seul : le snapshot est remplace parrename, un bind de fichier resterait accroche a l'ancien inode. A poser par Max avant le premier push de l'agent. Le sous-dossierhistory/(#55) vit sur ce meme montage : rien a poser, le serveur le cree au premier POST de plus d'un jour.- Deux couches tiennent l'API hors des fichiers Defenseurs. (1) Le mode du montage :
:roest 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 adefenseur: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), donctest -wy 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 denodedans l'image, et deubuntusur l'hote). Unchgrp ubuntuou unchmod o+wla ferait tomber sans bruit. Le:rone 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=falsesur les deux montages Defenseurs,RW=truesur/data/hostset/data/claude-usage) ;/proc/mountsdans le conteneur (vue du noyau) ; et le contenu des routes de lecture, pas seulement leur code, car un montage manquant repond 200no_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 filtrebranches:, pour que les maillons d'une pile soient batis) et surpushversmain. Un seul job encontainer: node:22-alpine, la base du Dockerfile :npm ci,npm test,bash test-curl.sh, puissh 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) — 731 cas__tests__/claude-usage.test.js—POST/GET /claude-usageetsanitizeClaudeUsage()(123 cas, issue #48 : contrat garde tel quel et idempotence ; corps entier refuse seulement hors objet ouschemaVersion!= 1 ; valeurs invalides jetees, pas forcees (champ inconnu a chaque niveau,__proto__, nombre negatif, au-dela de 2^53, fractionnaire,percenthors 0-100,statushors enum,detailhors regex, dates ISO, jour inexistant, cle de modele) ; bornes de liste ; cloisonnement des trois tokens dans les deux sens ; 503 nommant chacun sa variable ; espiontimingSafeEqual; 413 a 65 537 octets et 204 a 65 536,/hosts/<id>toujours 413 a 4 097 ; ecriture atomique en mode 600, et nettoyage du temporaire quand lerenameechoue apres son ecriture (#54 : cible = dossier non vide) ; aller-retour POST puis GET ; peremption a la seconde pres sous horloge figee ; re-sanitisation et degradation enno_dataa la lecture,receivedAtcompris s'il n'est pas une date ISO complete avec decalage (#54 :"1", date seule, heure sans decalage) ; journalisation des rejets ;claude-usage.jsdans leCOPY)__tests__/claude-usage-history.test.js— archive des jours clos etGET /claude-usage/history(76 cas, issue #55 : arithmetique des mois ;closedDays(date la plus recente et dates posterieures a la reception ecartees) ;mergeDaysavec ses deux regles (dernier gagne, date archivee gagne), resultat trie, entrees intactes ; regroupement par le prefixe de la chaine ; relecture d'un fichier mensuel par la liste blanche (champ inconnu,__proto__, valeur invalide, date hors du mois, fichier hors format -> illisible) ;parseRange(defauts, 36 mois pile, 37 refuses,fromseul trop ancien, formats) ; POST : tout sauf le dernier jour, mode 600, rien pour un snapshot d'un jour, second POST identique octet pour octet, jour remplace, jour sorti de la fenetre garde, deux mois, ordre d'envoi et date repetee, jour date apres la reception jamais archive, mois illisible ou hors format jamais ecrase (204, snapshot ecrit, autre mois ecrit, journal sans contenu), dossier impossible a creer,renameen echec sur un mois (temporaire supprime, l'autre mois ecrit), rien d'archive sur un POST refuse ou un snapshot non ecrit ; route : defauts, selection, mois illisible omis et journalise, re-sanitisation, fichier mensuel futur et nom de fichier parasite ignores comme fin par defaut, 400, token de lecture seul, 404 sur POST, aller-retour ; import lance en vrai processus : date archivee jamais remplacee, seulhistory/change, entree standard, fichier invalide (pas du JSON, sansdays, jour invalide, date repetee) qui n'ecrit rien, mois illisible saute, fichier absent, erreurs d'usage ;claude-usage-history.jsdans leCOPY)__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/defenseursapres extraction deserveJsonFile()(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— modulemetrics.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 (76 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateurBearerautre qu'un espace), quasi-rates du token sur les 7 routes de lecture (/claude-usage/historycomprise, #55), fail-closed, 3 cas404 (not 401)qui figent l'ordre routage -> auth, un espion surtimingSafeEqualqui exige deux empreintes de 32 octets a chaque requete, et des epingles sur le source : aucune egalite contre une chaineBearer, un seultokenMatches()et un seulreturn nulldanscheckAuth(), 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 (177 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche,loadAvga exactement 3 elements, 413,neverSeen, fraicheur serveur sous horloge figee, degradation de lecture (dont unreceivedAthors ISO complet, #54), temporaire supprime quand lerenameechoue (#54), balayage des.tmporphelins au demarrage, prouve sur le vrai point d'entree, symlink ni suivi ni supprime, ancre^deTEMP_FILE_RE)__tests__/agent.test.js— agent poste (50 cas : contrat de payload verifie contresanitizeSnapshot(), 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__/agent-claude-usage.test.js— agent claude-usage du ThinkPad (136 cas). Agregation des transcripts, #49 (52 cas) : jours Toronto et changement d'heure, regles de projet, deduplication des requetes, 30 jours, top 8 +(autres), 12 modeles, seuil de scan pris sur le plus ancien jour ouvert (jour fige futur, trou dans la serie figee, tout fige), noms__proto__etconstructorsans ecriture surObject.prototype; #54 : requete a cheval sur minuit comptee une seule fois (jours figes ecartes apres la deduplication, egalite d'outputgagnee par la ligne la plus ancienne quel que soit l'ordre de lecture), erreurs de lecture comptees (readdir,ENOENTd'un sous-dossier compris,staty comprisENOENT, flux, ouverture), racine absente non comptee. Agent, #50 (74 cas) : reponse anonymisee de l'endpoint (__tests__/fixtures/claude-usage/oauth-usage.json) vers les items, replifive_hour/seven_day, toutnull->unavailable;fetch(double) jamais appele sur jeton expire,off, credentials tronques, absents ou sans jeton, version absente ; 429 ->http_error/http_429avec les dernieres limites valides et leurfetchedAt; corps hostiles ->detaildans l'enum ; bearer OAuth envoye a la seule URL Anthropic epinglee, aucune variable d'env ne la change (epingle sur le source),redirect: "error"; espions sur toutes les ecritures denode:fs: le fichier de credentials n'est que lu ; aucun fragment de 5 caracteres des deux jetons dans le snapshot, l'etat ni la sortie (dont un vrai processus) ; deux passages autour d'un jour vide (fige, puis aucun fichier sous le seuil ouvert) ;state.jsoncorrompu, elagage a 35 jours, ecriture atomique en 600, temporaire laisse en 644 ramene a 600 (#54) ; passage avec erreur de lecture : aucun jour fige, snapshot envoye,read_errors=Nsans chemin, le passage suivant fige (#54) ; reponse de l'endpoint comptee en octets pendant la lecture, 256 Kio pile acceptes, + 1 octet en caracteres de 2 octets refuse, flux sans fin annule (#54) ; snapshot sur fixtures inchange parsanitizeClaudeUsage;http:-> 1, > 64 Kio -> 2 sans envoi ;run-claude-usage.shlance deux fois en parallele (fauxnode, HOME temporaire) : le second sort 0 sans travailler. Rattrapage ponctuel, #55 (10 cas) : fenetres passees contigues jusqu'a J-2, jours vides de tete retires, requete a cheval sur la frontiere de deux fenetres comptee une fois (meilleure ligne avant ou apres minuit), rien d'autre que{ date, models }, borne--max-windows, erreurs de lecture additionnees ;mainn'ecrit que--out(mode 600, jamais ecrase), aucunfetch, aucun acces aux credentials, sortie en compteurs seulement, fichier accepte par le parseur d'import du serveur, erreurs d'usage__tests__/ci.test.js— epingles du workflow et des scripts de CI (49 cas : image ducontainer:identique auFROMdu Dockerfile, les quatre etapes presentes, aucun filtrebranches:souspull_request,pushlimite amain, nidefaults:niworking-directorysur une etapeuses:, aucuncontinue-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,CMDen forme exec, le refus d'un port deja occupe (voisin HTTP reel, script lance enspawnasynchrone), aucune requete par un proxy de l'environnement (boot complet derriere un faux proxy qui repond a tout, sa vivacite prouvee d'abord par uncurlnu, avec unno_proxyqui laisse la boucle locale hors liste : exporterNO_PROXYseul ne suffit pas),test-curl.shlance en entier (#41 et #46, 22 cas, chacun sur des ports libres qui lui sont propres, passes parTEST_CURL_PORTetTEST_CURL_NOINGEST_PORT: derriere le meme faux proxy avecFORCE_COLOR=3, avecBASE_URLetNOINGEST_URLpointes sur un port mort, et avecTOKENetINGEST_TOKENherites d'une meme valeur, rien n'atteint le proxy, les 51 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, 51 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 fauxnodeen tete dePATHqui 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 unbash test-curl.sha la main. Les ports viennent defreePorts(), qui ouvre ses sondes ensemble puis les ferme avant le lancement : un port repris dans l'intervalle fait echouer le cas avec le message derequire_free, sans cas lance contre un mauvais serveur), et desrequireen colonne 0 seulement, sansimport(), dans chaque.jsduCOPY. 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 :requireindente etimporta toute colonne, avec ou sans espace, signales ;requirede 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");), unrequire(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 derequire)__tests__/writes.test.js— surfaces d'ecriture :HOSTS_DIR, et le dossier deCLAUDE_USAGE_PATHpour le seulPOST /claude-usage, archivehistory/comprise (#55) (#48 :violations()prend le dossier autorise du cas,HOSTS_DIRpar defaut ; deux cas dedies,POST /claude-usagen'ecrit que dans son dossier, et ses rejets 401/400/413 et la lecture d'un fichier corrompu n'ecrivent rien du tout ;GET /claude-usageentre dans les routes de lecture ; #55 : le cas POST exige lerenamedu fichier mensuel du jour clos,GET /claude-usage/historyentre dans les routes de lecture, et un mois corrompu ou une plage refusee n'ecrivent rien) (15 cas : espions sur toutes les fonctions d'ecriture denode:fs, formes callback, Sync etfs.promises, et surchild_process, poses avant unrequirefrais apres purge du cache de tous les modules runtime (unmetrics.jsreste en cache garderait les espions restaures d'un test precedent) ; demarrage par le balayage des.tmp; les huit routes GET n'ecrivent rien, meme une macrotache apres la reponse, contenu reel exige ;POST /hosts/<id>n'ecrit que sousHOSTS_DIR; chemins degrades : rejets 401/403/404, entrees 400/413,agents-map.jsoncorrompu, fichiers Defenseurs absents, snapshot corrompu, avec le contenu degrade exige (no_data,neverSeen) ; commandes shell figees surhead -1 /proc/statetdf -k /,/healthservant de temoin ; chaque cas prouve que ses espions sont vivants ; tout appel est compte, sans attribution par pile (#40 : une fonctionnode:fspassee telle quelle aprocess.nextTick, a un ecouteur ou a un.thens'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 unrmSyncrecursif, qui appelle en interne lefs.rmdirSyncpublic, donc l'espion ; epingle sur le blocrequire.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 leCMDdepuis les seules sources duCOPY, copiees dans un dossier vide, et exige 401 sans token, 200 avec, et des metriques disque/memoire non nulles. Tient lieu dedocker builden 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 ligneCOPYen forme shell sans option, et unCMDen forme exec ; toute autre forme echoue bruyamment plutot que de verifier du vide. Port 3097 par defaut (BOOT_PORTpour 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 exporteNO_PROXY='*'etno_proxy='*'en tete : chaquecurlvise la boucle locale, et unhttp_proxyde l'environnement repondrait a la place du port comme du serveur demarre. Les deux graphies, car curl lit d'abord la minuscule : unno_proxyherite l'emporterait sur unNO_PROXYexporte seul. Il n'appelle que/health: il ne prouve que les modules charges au demarrage, d'ou l'epingle «requireen colonne 0 » dansci.test.js.bash test-curl.sh— passe au niveau socket, complementaire et non redondante (51 cas, dont 6 sur/claude-usage/historydepuis #55 : 401 sans token et avec le token d'ingestion Claude, 400, POST d'un snapshot de deux jours puis le seul jour clos servi, fichier mensuel en mode 600). 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 sansHOSTS_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 parTEST_CURL_PORTetTEST_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 quePORT, qu'un shell de dev exporte souvent deja.BASE_URLetNOINGEST_URLsont derives des ports et ne se lisent plus dans l'environnement : surcharges seuls, ils envoyaient lescurlailleurs que sur les serveurs demarres.TOKENetINGEST_TOKENsont fixes pour la meme raison : deux valeurs egales heritees cassaient les cas de cloisonnement. Quatre protections contre l'environnement (#41) :NO_PROXY='*'etno_proxy='*'exportes, comme dansboot-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 variablenode -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 queboot-from-copy.sh) : c'est lui qui ecarte un voisin deja en place, car le serveur du script ne meurt de sonEADDRINUSEqu'apres que la boucle d'attente a eu le temps de joindre le voisin ; etwait_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. Letrap EXITprotege chaquekillpar|| true(sousset -e, unkillen echec sur le premier serveur deja mort terminait le trap et laissait le second tourner sur son port) et finit par unwait, pour qu'un passage lance juste apres trouve les ports libres. Cewaitn'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 majusculeNO_PROXY, que curl ne lit qu'apres la minuscule, et le secondkill -0dewait_for, qui ne couvre que la fenetre residuelle entrerequire_freeet lelistendu serveur.
Gotchas
- Pas d'Express — HTTP natif Node.js uniquement
- Le
COPYdu Dockerfile liste les fichiers un par un (package.json index.js metrics.js claude-usage.js claude-usage-history.js) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur unMODULE_NOT_FOUNDquenpm testne voit pas (seulscripts/boot-from-copy.sh, en CI, le rattrape). Unrequireplace dans un handler de route echapperait au boot check (qui n'appelle que/health) : garder tous lesrequireau niveau module, une epingle deci.test.jsl'exige. Corollaire :agent/n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voiragent/README.md), et un test epingle que leCOPYne le mentionne jamais. agent/tourne sur le ThinkPad, pas sur le VPS, et sa sortie part danslogger -t host-agent: tout ce qu'il imprime finit dans/var/log/sysloget journald pour de bon. Le chemin d'erreur ne rend donc queerr.codeet le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais deset -xdansrun-push.sh: le shell echoerait le token en sourcant le fichier d'env.agent/claude-usage/(#50,logger -t claude-usage, cron 15 min) tient deux secrets dans le meme processus : le jeton OAuth de Claude Code etCLAUDE_USAGE_INGEST_TOKEN. Il ne rafraichit jamais le jeton OAuth et n'ecrit jamais~/.claude/.credentials.json(refresh token a usage unique : Claude Code serait deconnecte) ; jeton expire ->token_expired, aucun appel. L'URL Anthropic est une constante delimits.js(aucune variable d'env,redirect: "error") pour que le bearer OAuth ne suive jamais l'URL de push configurable ;detailne sort que de l'enum, jamais d'un message d'erreur ou d'un corps de reponse. Memes regles de sortie que l'agent poste (codes seulement, pas deset -xdansrun-claude-usage.sh). Un jour se fige a J+2 et n'est plus jamais recalcule : un passage qui a rencontre une erreur de lecture (readErrors > 0) ne fige donc aucun nouveau jour et journaliseread_errors=N, sans chemin (#54) ; et les jours figes sont ecartes apres la deduplication, jamais avant, sinon une requete a cheval sur minuit est comptee deux fois.getHealth()gardePromise.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/healtha ~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.jsonet les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanchePOST /hosts/<id>ecrit dansHOSTS_DIR, etPOST /claude-usage(#48) dans le dossier deCLAUDE_USAGE_PATH. Consequence : ces deux montages-la doivent etre en lecture-ecriture pour l'uid 1000, alors que les deux montages Defenseurs sont en:ro(#36). Une ecriture ajoutee un jour sousREPORTS_DIRou/data/defenseurspasserait tous les autres tests (fixtures inscriptibles, CI en root) et tomberait en prod en 500EROFS:__tests__/writes.test.jsexige que toute ecriture parnode:fsviseHOSTS_DIR(ou, pour le seul casPOST /claude-usage, le dossier deCLAUDE_USAGE_PATH), et que les seules commandes shell soient les deux lectures demetrics.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—renameest atomique dans un systeme de fichiers, donc unGET /hostsconcurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » enwriteFileSyncdirect. Un processus tue entre les deux laisse un.<id>.<pid>.<ms>.tmp:sweepStaleTempFiles()les supprime au demarrage, avantlisten, 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 changerTEMP_FILE_RE.writeClaudeUsage()suit le meme patron (temporaire.claude-usage.json.<pid>.<ms>.tmp), mais aucun balayage ne couvre son dossier : un orphelin y resterait, inoffensif (GET /claude-usagene lit que le fichier final) ; un seul fichier ecrit toutes les quelques minutes ne justifiait pas d'elargirsweepStaleTempFiles()et le blocrequire.mainepingle. Meme chose pourhistory/(#55) : temporaires.YYYY-MM.json.<pid>.<ms>.tmp, aucun balayage, un orphelin y est inoffensif (la route ne lit queYYYY-MM.json). - 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 parGET /hostset finit dans l'arbre React de l'admin : une cle__proto__ou unhostnamede 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.loadAvgdoit avoir exactement 3 nombres (la carte admin les joint par « / ») : un fichier ancien hors format degrade enneverSeen. 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 est passe par route a
readBody(req, res, maxBytes): 4 Kio (MAX_BODY_BYTES) pour/hosts/<id>, 64 Kio (CLAUDE_USAGE_MAX_BODY_BYTES, exporte parclaude-usage.js) pour/claude-usage, le pire cas realiste estime par la spec pesant ~46 Ko (un corps qui sature toutes les bornes a la fois, cles de 64 caracteres et compteurs a 16 chiffres partout, depasserait 64 Kio et recevrait 413 : le plafond d'octets prime sur les bornes de liste). Il compte les octets recus, pas leContent-Length: un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puisreq.destroy()une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code). agents-map.jsondoit etre present sur le VPS avant le deploy : verifier viassh ubuntu@vps 'ls /data/defenseurs/agents-map.json'(PAS/home/defenseur/..., leurre obsolete). Sinon/defenseurs/findingsretourne 500.- Coolify ignore silencieusement
-vdanscustom_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 parrenameatomique, un bind de fichier resterait accroche a l'ancien inode. Details dansla-compagnie-maximus/docs/coolify-ops.md. - Severity threshold est asymetrique :
?severity=LOWretourne LOW+MEDIUM+HIGH+CRITICAL mais cache INFO. INFO est seulement accessible via?severity=INFOexplicite (cache le bruit par defaut).