Open the API's first write surface. Workstations push their CPU / memory /
disk snapshot, the admin dashboard reads it back with a server-computed
freshness.
Routing moves from an exact path list to a descriptor table, keeping ONE
authentication checkpoint:
resolveRoute() -> no match means an immediate 404, deny by default
checkAuth() -> the single gate; only the expected token varies per
descriptor (HEALTH_TOKEN for reads, HOSTS_INGEST_TOKEN
for ingestion)
dispatch
The routing-before-auth ordering is preserved on purpose: an unknown path or
a wrong method still answers 404, never 401, exactly as before. The two
`404 (not 401)` tests added in #12 pin that ordering and still pass.
Security controls on the new write path:
- The route regex ^/hosts/([a-z0-9][a-z0-9-]{0,31})$, POST-only, IS the path
traversal control. URL() normalises /hosts/../../etc/passwd to /etc/passwd
and encoded traversals fail the match, so every hostile id lands on 404.
403 is reserved for well-formed ids outside the allowlist.
- 401 wins over 403, so an unauthenticated caller cannot probe the allowlist.
- tokenMatches() compares SHA-256 digests with timingSafeEqual. Scoped to the
ingestion path only; realigning HEALTH_TOKEN touches every read route in
production and is tracked separately.
- Body capped at 4 KiB by counting received bytes, never Content-Length:
a client can lie in the header and chunked encoding omits it. Past the cap,
413 then req.destroy() once the response has flushed.
- The persisted object is rebuilt field by field from a whitelist — finite
numbers, strings capped at 128 chars, everything else dropped — because the
file is read back by GET /hosts and ends up in the admin React tree. The
read path re-runs the same whitelist: the bind-mount is writable, so the
file on disk earns no more trust than the payload did.
- Snapshots are written to a temp file in the same directory then renamed, so
a concurrent GET /hosts can never observe a truncated JSON.
- HOSTS_ALLOWED_IDS entries are validated at startup with the same regex;
rejects are logged and dropped rather than becoming file paths.
- Every 401/403 is logged with X-Real-IP, the host id and the reason. Log
values are filtered to printable ASCII so a crafted header cannot forge
extra log lines.
- receivedAt is stamped by the server on arrival; a client-supplied timestamp
is discarded by the whitelist. online = ageSeconds <= HOSTS_STALE_SECONDS.
Runtime stays zero-dependency — node:crypto is a builtin and no new module
file was added, so the Dockerfile's explicit COPY list is unchanged.
Tests: 61 (39 existing untouched + 22 new). __tests__/hosts.test.js is smoke
coverage of the decisions that would be silent to regress; the exhaustive
matrix is issue #14.
Docs: .env.example gains HOSTS_DIR / HOSTS_ALLOWED_IDS / HOSTS_INGEST_TOKEN /
HOSTS_STALE_SECONDS; CLAUDE.md and README.md document the endpoints, the
routing/auth ordering and the config. The CLAUDE.md "read-only" gotcha is
corrected — the API now writes, and HOSTS_DIR must be writable by uid 1000.
Resolves #13
10 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 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) protege uniquement le chemin d'ingestion. Le realignement duHEALTH_TOKENexistant 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_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_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+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
npm test(vitest) — 61 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— 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 (19 cas : matrice route x mode d'echec, en-tetes malformes, fail-closed, et 2 cas404 (not 401)qui figent l'ordre routage -> auth)__tests__/hosts.test.js— smoke des endpoints postes (22 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, entree corrompue degradee). La matrice exhaustive est l'issue #14.
- Runtime reste 0-dep ; vitest en devDep uniquement
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_FOUNDque les tests locaux ne voient pas. 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).