test-curl.sh predates vitest and still claimed to be the authoritative suite for one endpoint. It is now the socket-level pass, and its header says so: vitest covers the logic, this script covers what only a real client and a real TCP connection can show. Twenty-four cases for POST /hosts/<id> and GET /hosts, chosen for what they prove rather than for coverage: the 413 answered before the connection is cut, the two tokens refusing each other's routes in both directions, 401 landing ahead of 403 so an unauthenticated caller cannot probe the allowlist, the route regex turning a malformed id and a traversal attempt into 404 before any handler runs, and the ingest-persist-read round trip closing on a GET that shows the pushed hostname online. Two cases needed care to be worth anything: The hostile payload splices "__proto__" into the JSON as text. Written as __proto__: in an object literal it would set the prototype and JSON.stringify would emit nothing, leaving the case asserting against a payload that never carried the key. It now checks the response and the persisted file, whose key set must be exactly the whitelist. The 413 case runs under set -e while the server destroys the socket right after flushing the response, so curl can exit 55/56 having already read the status line. Every probe goes through an http_code helper that absorbs that; a curl which truly got nothing reports 000, which fails the case rather than passing it quietly. -H "Expect:" also suppresses the 100-continue handshake, which otherwise changes which side notices the reset first. The fail-closed 503 needs a server booted without HOSTS_INGEST_TOKEN, so the script now runs a second instance on 3098 for it, and confirms reads still answer 200 there — the two tokens are independent, including in absence. The EXIT trap dereferenced an unset SERVER_PID under set -u, which made it error out instead of cleaning up when anything failed before the boot. Both PIDs are initialised and the kills guarded. CLAUDE.md: the vitest count was stale (244 -> 251, auth.test.js 19 -> 26) and test-curl.sh was undocumented. Refs #16 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 lines
11 KiB
Markdown
80 lines
11 KiB
Markdown
# VPS Health API
|
|
|
|
API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
|
|
|
|
## Endpoints
|
|
|
|
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
|
|
- `GET /defenseurs` — contenu de status.json (rapports defenseurs)
|
|
- `GET /reports/scans?date=YYYY-MM-DD` — agrege les rapports `defenseur-<agent>_<date>*.json` du jour, format `{ date, count, reports: Report[] }`. Filtre `isScanReport` (exclut `defenseur-auto_*.json`). Date validee par regex (path traversal bloque). Consommateur : `defenseur-auto` workstation cron (remplace le pre-rsync SSH). Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/reports/scans?date=2026-05-07"`.
|
|
- `GET /defenseurs/findings?project=X` — findings detailles du Defenseur correspondant. Query params : `project=<name>` (obligatoire, lookup via `agents-map.json`), `category=<deps|secrets|code|acces|infra>` (optionnel, exact match), `severity=<CRITICAL|HIGH|MEDIUM|LOW|INFO>` (optionnel, threshold inclusif vers le haut). Sans `severity` -> MEDIUM+HIGH+CRITICAL (cache LOW+INFO). `severity=LOW` -> LOW+MEDIUM+HIGH+CRITICAL (cache toujours INFO, asymetrie volontaire). `severity=INFO` -> INFO uniquement (opt-in explicite). Reponses : 200 `{ agent, project, timestamp, findings[] }` si report present (sans champ `status`) ; 200 `{ findings: [], status: "no_data" }` si pas de report ; 400 sans project ou param invalide ; 404 projet inconnu ; 500 si `agents-map.json` corrompu. Consommateurs : admin dashboard Vercel (drill-down), futur skill `/analyse-vulnerabilite`. Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"`.
|
|
|
|
- `GET /hosts` — dernier snapshot de chaque poste de l'allowlist. Format `{ staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }`. `online = ageSeconds <= HOSTS_STALE_SECONDS` (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en `{ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }` sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel.
|
|
- `POST /hosts/<id>` — **seule surface d'ecriture de l'API**. Auth par `HOSTS_INGEST_TOKEN` (pas `HEALTH_TOKEN`). Corps = payload de `GET /health` moins `logto` et `timestamp`. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 `<id>` bien forme mais hors allowlist / 404 `<id>` hors format / 413 corps > 4 Kio (connexion coupee) / 503 `HOSTS_INGEST_TOKEN` non configure. Exemple : `curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad"`.
|
|
|
|
## Routage et auth
|
|
|
|
Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` :
|
|
|
|
1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token.
|
|
2. `checkAuth()` — **un seul point de controle**, dont seul le token attendu varie (`tokenKind: "read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee.
|
|
3. Dispatch vers le handler.
|
|
|
|
Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord.
|
|
|
|
- Fail-closed : `HEALTH_TOKEN` absent -> 401 sur les lectures ; `HOSTS_INGEST_TOKEN` absent -> 503 sur l'ingestion
|
|
- `tokenMatches()` (`timingSafeEqual` sur empreintes SHA-256) protege **uniquement** le chemin d'ingestion. Le realignement du `HEALTH_TOKEN` existant est suivi separement (issue #17) : il toucherait tous les chemins de lecture en production
|
|
- 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist
|
|
- Chaque rejet 401/403 est journalise (`[auth] <code> <method> <url> ip=<X-Real-IP> id=<host> reason=<motif>`), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log
|
|
- **Coolify** : `HEALTH_TOKEN` et `HOSTS_INGEST_TOKEN` doivent etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
|
|
|
|
## Config
|
|
|
|
- Port : `3001` (env `PORT`)
|
|
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
|
|
- `REPORTS_DIR` : dossier lu par `/reports/scans` et `/defenseurs/findings` (default `/data/defenseurs/reports`)
|
|
- `DEFENSEURS_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`)
|
|
- `HOSTS_DIR` : dossier **ecrit** par `POST /hosts/<id>`, un `<id>.json` par poste (default `/data/hosts`)
|
|
- `HOSTS_ALLOWED_IDS` : allowlist separee par virgules (default `thinkpad` — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee
|
|
- `HOSTS_INGEST_TOKEN` : token d'ingestion, distinct de `HEALTH_TOKEN` (pas de default — absent = 503)
|
|
- `HOSTS_STALE_SECONDS` : age au-dela duquel un poste est reporte hors ligne (default 900)
|
|
- Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) :
|
|
- `/data/defenseurs` (host) -> `/data/defenseurs` : `status.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Lecture seule** pour cette API.
|
|
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10). **Lecture seule** pour cette API.
|
|
- `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500.
|
|
|
|
## Deploy
|
|
|
|
Pas d'auto-deploy : l'app Coolify n'a pas de Source Forgejo (`source_id=null`, migration trackee dans la-compagnie-maximus#133). Apres un merge sur main, trigger manuel :
|
|
|
|
```bash
|
|
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) — 251 cas
|
|
- `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
|
|
- `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence)
|
|
- `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (26 cas : matrice route x mode d'echec, en-tetes malformes, fail-closed, et **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**)
|
|
- `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (155 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture)
|
|
- `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**)
|
|
- Runtime reste 0-dep ; vitest en devDep uniquement
|
|
- `bash test-curl.sh` — passe au niveau socket, complementaire et non redondante (41 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, 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.
|
|
|
|
## Gotchas
|
|
|
|
- Pas d'Express — HTTP natif Node.js uniquement
|
|
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que les tests locaux ne voient pas. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais.
|
|
- `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env.
|
|
- `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`.
|
|
- L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/<id>` ecrit dans `HOSTS_DIR`. Consequence : ce montage-la doit etre en lecture-ecriture pour l'uid 1000, contrairement aux montages `/data/defenseurs`.
|
|
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `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.
|
|
- L'objet persiste est **reconstruit champ par champ** depuis une liste blanche (nombres via `Number.isFinite`, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par `GET /hosts` et finit dans l'arbre React de l'admin : une cle `__proto__` ou un `hostname` de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload.
|
|
- Le plafond de corps a 4 Kio compte les octets **recus**, pas le `Content-Length` : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis `req.destroy()` une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code).
|
|
- `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500.
|
|
- Coolify ignore silencieusement `-v` dans `custom_docker_run_options` — les volumes passent par les Persistent Storages (UI seulement). Details dans `la-compagnie-maximus/docs/coolify-ops.md`.
|
|
- Severity threshold est asymetrique : `?severity=LOW` retourne LOW+MEDIUM+HIGH+CRITICAL mais cache INFO. INFO est seulement accessible via `?severity=INFO` explicite (cache le bruit par defaut).
|