Zero-dependency local agent: collect one snapshot through the shared metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five minutes. Nothing is queued and nothing is replayed — a heartbeat from five minutes ago describes a machine that no longer exists, so a failed push is logged and dropped rather than spooled. run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never enables shell tracing: the script is meant to be piped into `logger`, where `set -x` would echo the ingest token into /var/log/syslog and journald for good. Same reason the failure path reports only err.code and the HTTP status — never an error object, request options, headers, or a response body. The cost is accepted: a 400 says the payload was rejected, not why. 50 tests, including two that spawn the real process against a failing server and scan its actual stdout and stderr for any five-character fragment of the token. The payload is pinned against the server's own sanitizeSnapshot(), and one case pushes a real collectMetrics() snapshot through the real ingestion handler, so a drift between agent and server turns a test red instead of producing a 400 at 3 a.m. on the ThinkPad. agent/ stays out of the Docker image: the COPY line is untouched, and a test pins that it copies files one by one and never names the directory. Installing on a workstation — frozen copy, env file, crontab line, dry run, and why two machines must never share a HOST_ID — is documented in agent/README.md. The install itself belongs to the commissioning issue. Resolves #15
79 lines
11 KiB
Markdown
79 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) — 244 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 (19 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
|
|
|
|
## 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).
|