Nothing verified this repository until now. The workflow runs on every pull request (no branches filter, so intermediate stack links get a status) and on pushes to main, in node:22-alpine, the Dockerfile's own base: npm ci, npm test, the socket-level pass, then scripts/boot-from-copy.sh. The script stands in for a docker build, which would need the Docker socket in the instance-scoped runner and so root on the production VPS for every workflow. It starts the CMD from the COPY sources alone and fails on the MODULE_NOT_FOUND that #11 had to fix by hand. __tests__/ci.test.js pins the workflow and what the script assumes of the Dockerfile. Refs #24 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 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>— seule surface d'ecriture de l'API. 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".
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 (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.- 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 ;HOSTS_INGEST_TOKENabsent -> 503 sur l'ingestion tokenMatches()(timingSafeEqualsur 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 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)- 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.jsonest un leurre obsolete lu par personne. Lecture seule pour cette API./home/defenseur/defenseurs/reports(host) ->/data/defenseurs/reports: rapports de scan (+ sous-dirarchive/). 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
- 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) — 308 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/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 (67 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateurBearerautre qu'un espace), quasi-rates du token sur les 6 routes de lecture, fail-closed, 2 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 (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 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__/ci.test.js— epingles du workflow (7 cas : image ducontainer:identique auFROMdu Dockerfile, les quatre etapes presentes, aucun filtrebranches:souspull_request,pushlimite amain, nidefaults:niworking-directorysur une etapeuses:, et ce que le boot check suppose du Dockerfile :USER node,CMDen forme exec)
- 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.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 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.
Gotchas
- Pas d'Express — HTTP natif Node.js uniquement
- Le
COPYdu 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 unMODULE_NOT_FOUNDquenpm testne voit pas (seulscripts/boot-from-copy.sh, en CI, le rattrape). 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.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. 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—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. - 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. - 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 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). 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).