Compare commits
No commits in common. "main" and "issue-4-runtime-only-coolify-secrets" have entirely different histories.
main
...
issue-4-ru
18 changed files with 104 additions and 5422 deletions
19
.env.example
19
.env.example
|
|
@ -4,22 +4,3 @@ PORT=3001
|
||||||
# Buildtime ARG leaks the secret in clear in application_deployment_queues.logs.
|
# Buildtime ARG leaks the secret in clear in application_deployment_queues.logs.
|
||||||
HEALTH_TOKEN=change-me-to-a-strong-secret
|
HEALTH_TOKEN=change-me-to-a-strong-secret
|
||||||
LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration
|
LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration
|
||||||
# Directory served by GET /reports/scans. Bind-mount target on Coolify —
|
|
||||||
# parent /data/defenseurs/ is already mounted (status.json sits next to it).
|
|
||||||
REPORTS_DIR=/data/defenseurs/reports
|
|
||||||
|
|
||||||
# --- Workstation snapshots (POST /hosts/<id>, GET /hosts) --------------------
|
|
||||||
# Directory the API WRITES workstation snapshots into, one <id>.json per host.
|
|
||||||
# Unlike the /data/defenseurs mounts this one must be writable by uid 1000
|
|
||||||
# (the `node` user the container runs as) or every ingest answers 500.
|
|
||||||
HOSTS_DIR=/data/hosts
|
|
||||||
# Comma-separated allowlist of host ids. Each entry must match
|
|
||||||
# ^[a-z0-9][a-z0-9-]{0,31}$ — anything else is logged and dropped at startup.
|
|
||||||
HOSTS_ALLOWED_IDS=thinkpad
|
|
||||||
# Bearer token for POST /hosts/<id>. Separate from HEALTH_TOKEN on purpose: a
|
|
||||||
# workstation agent should be able to write its own snapshot without gaining
|
|
||||||
# read access to the Defenseurs reports. Same Coolify rule as HEALTH_TOKEN —
|
|
||||||
# is_runtime=true, is_buildtime=false. Unset -> POST /hosts/<id> answers 503.
|
|
||||||
HOSTS_INGEST_TOKEN=change-me-to-a-strong-secret
|
|
||||||
# Age (seconds) past which GET /hosts reports a host as offline. Default 900.
|
|
||||||
HOSTS_STALE_SECONDS=900
|
|
||||||
|
|
|
||||||
66
CLAUDE.md
66
CLAUDE.md
|
|
@ -1,79 +1,29 @@
|
||||||
# VPS Health API
|
# VPS Health API
|
||||||
|
|
||||||
API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
|
API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
|
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
|
||||||
- `GET /defenseurs` — contenu de status.json (rapports defenseurs)
|
- `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.
|
## Auth
|
||||||
- `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
|
- Bearer token via env `HEALTH_TOKEN`
|
||||||
|
- Fail-closed : si `HEALTH_TOKEN` non configure, toutes les requetes sont refusees
|
||||||
Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` :
|
- **Coolify** : `HEALTH_TOKEN` doit 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".
|
||||||
|
|
||||||
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
|
## Config
|
||||||
|
|
||||||
- Port : `3001` (env `PORT`)
|
- Port : `3001` (env `PORT`)
|
||||||
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
|
- `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`)
|
- Bind-mount : `/data/defenseurs/status.json` read-only
|
||||||
- `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
|
## 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 :
|
Coolify auto-rebuild depuis push Forgejo. Aucune action manuelle requise.
|
||||||
|
|
||||||
```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
|
## Gotchas
|
||||||
|
|
||||||
- Pas d'Express — HTTP natif Node.js uniquement
|
- 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.
|
- Le `status.json` est ecrit par le Sergent defenseurs, pas par cette API (read-only)
|
||||||
- `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).
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json index.js metrics.js ./
|
COPY package.json index.js ./
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
USER node
|
USER node
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|
|
||||||
136
README.md
136
README.md
|
|
@ -1,136 +0,0 @@
|
||||||
# vps-health-api
|
|
||||||
|
|
||||||
Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtime deps.
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
Read endpoints require `Authorization: Bearer $HEALTH_TOKEN`. The single write
|
|
||||||
endpoint uses its own token, `$HOSTS_INGEST_TOKEN`, so a workstation agent can
|
|
||||||
push its snapshot without gaining read access to the Defenseurs reports.
|
|
||||||
|
|
||||||
| Method | Path | Token | Description |
|
|
||||||
|--------|------|-------|-------------|
|
|
||||||
| GET | `/health` | `HEALTH_TOKEN` | CPU, memory, disk, uptime, Logto reachability |
|
|
||||||
| GET | `/defenseurs` | `HEALTH_TOKEN` | Defenseurs executive status (status.json) |
|
|
||||||
| GET | `/defenseurs/findings?project=X` | `HEALTH_TOKEN` | Detailed findings for a project's Defenseur |
|
|
||||||
| GET | `/reports/scans?date=YYYY-MM-DD` | `HEALTH_TOKEN` | Aggregated scan reports for a UTC date |
|
|
||||||
| GET | `/hosts` | `HEALTH_TOKEN` | Latest snapshot of every allowlisted workstation |
|
|
||||||
| POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot |
|
|
||||||
|
|
||||||
Routing resolves before authentication: an unknown path or a wrong method
|
|
||||||
answers `404`, never `401`.
|
|
||||||
|
|
||||||
### `POST /hosts/<id>`
|
|
||||||
|
|
||||||
Body: the `GET /health` payload minus `logto` and `timestamp` — `hostname`,
|
|
||||||
`uptime`, `cpu{model,cores,loadAvg,usagePercent}`,
|
|
||||||
`memory{totalGB,usedGB,freeGB,usagePercent}`, `disk{…same four…}`.
|
|
||||||
|
|
||||||
`<id>` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in
|
|
||||||
`HOSTS_ALLOWED_IDS`. That regex is the path-traversal control — anything that
|
|
||||||
fails it never reaches a handler.
|
|
||||||
|
|
||||||
| Code | Case |
|
|
||||||
|------|------|
|
|
||||||
| 204 | Snapshot accepted and written |
|
|
||||||
| 400 | Unparsable JSON, missing field, or wrong type |
|
|
||||||
| 401 | Ingest token missing or wrong |
|
|
||||||
| 403 | `<id>` well-formed but not in the allowlist |
|
|
||||||
| 404 | `<id>` outside the format (never reaches the handler) |
|
|
||||||
| 413 | Body past 4 KiB — connection cut |
|
|
||||||
| 503 | `HOSTS_INGEST_TOKEN` not configured (fail-closed) |
|
|
||||||
|
|
||||||
The stored object is rebuilt field by field from a whitelist (numbers must be
|
|
||||||
finite, strings are capped at 128 chars, everything else dropped) and written
|
|
||||||
atomically via a temp file + `rename`. `receivedAt` is stamped by the server on
|
|
||||||
arrival; a client-supplied timestamp is ignored.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" --data @snapshot.json \
|
|
||||||
"https://health.lacompagniemaximus.com/hosts/thinkpad"
|
|
||||||
```
|
|
||||||
|
|
||||||
### `GET /hosts`
|
|
||||||
|
|
||||||
```
|
|
||||||
{ "staleAfterSeconds": 900,
|
|
||||||
"hosts": [ { "id", "hostname", "uptime", "cpu", "memory", "disk",
|
|
||||||
"receivedAt", "ageSeconds", "online" } ] }
|
|
||||||
```
|
|
||||||
|
|
||||||
One entry per allowlisted id. `online = ageSeconds <= staleAfterSeconds`,
|
|
||||||
computed server-side. A host that never checked in — or whose snapshot file is
|
|
||||||
unreadable — degrades to `{ id, receivedAt: null, ageSeconds: null, online:
|
|
||||||
false, neverSeen: true }` instead of failing the response, so the dashboard can
|
|
||||||
say "agent not installed" during commissioning.
|
|
||||||
|
|
||||||
### `GET /defenseurs/findings`
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
|
|
||||||
- `project` (required) — project name, looked up in `agents-map.json` (e.g. `la-suite-booking`)
|
|
||||||
- `category` (optional) — exact match, one of `deps|secrets|code|acces|infra`
|
|
||||||
- `severity` (optional) — threshold, one of `CRITICAL|HIGH|MEDIUM|LOW|INFO`
|
|
||||||
- default (no param): `MEDIUM`, `HIGH`, `CRITICAL`
|
|
||||||
- `LOW` returns `LOW`+`MEDIUM`+`HIGH`+`CRITICAL` but still hides `INFO`
|
|
||||||
- `INFO` returns `INFO` only (explicit opt-in)
|
|
||||||
|
|
||||||
Responses:
|
|
||||||
|
|
||||||
- `200 { agent, project, timestamp, findings: Finding[] }` — report present (empty `findings` if clean scan; no `status` field)
|
|
||||||
- `200 { findings: [], status: "no_data" }` — no report on file for the agent
|
|
||||||
- `400` — missing `project` or invalid `category` / `severity`
|
|
||||||
- `401` — missing or invalid token
|
|
||||||
- `404` — unknown project
|
|
||||||
- `500` — `agents-map.json` unreadable or corrupted
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
|
|
||||||
"https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
| Env var | Default | Purpose |
|
|
||||||
|---------|---------|---------|
|
|
||||||
| `PORT` | `3001` | HTTP port |
|
|
||||||
| `HEALTH_TOKEN` | — | Bearer token for the read routes (fail-closed if missing) |
|
|
||||||
| `REPORTS_DIR` | `/data/defenseurs/reports` | Scan reports dir |
|
|
||||||
| `DEFENSEURS_AGENTS_MAP_PATH` | `/data/defenseurs/agents-map.json` | Project -> agent snapshot |
|
|
||||||
| `LOGTO_HEALTH_URL` | auth.lacompagniemaximus.com | Logto OIDC discovery URL |
|
|
||||||
| `HOSTS_DIR` | `/data/hosts` | Where workstation snapshots are **written** |
|
|
||||||
| `HOSTS_ALLOWED_IDS` | `thinkpad` | Comma-separated allowlist of host ids |
|
|
||||||
| `HOSTS_INGEST_TOKEN` | — | Bearer token for `POST /hosts/<id>` (503 if missing) |
|
|
||||||
| `HOSTS_STALE_SECONDS` | `900` | Age past which a host is reported offline |
|
|
||||||
|
|
||||||
## Bind-mounts (Coolify)
|
|
||||||
|
|
||||||
Read-only for the API — written by the Defenseurs Sergent:
|
|
||||||
|
|
||||||
- `/home/defenseur/defenseurs/status.json` -> `/data/defenseurs/status.json`
|
|
||||||
- `/home/defenseur/defenseurs/reports/` -> `/data/defenseurs/reports/`
|
|
||||||
- `/home/defenseur/defenseurs/agents-map.json` -> `/data/defenseurs/agents-map.json`
|
|
||||||
|
|
||||||
Read-write — the API is the writer:
|
|
||||||
|
|
||||||
- `<host>/data/hosts/` -> `/data/hosts/` (`HOSTS_DIR`). Must be writable by uid
|
|
||||||
1000, the `node` user the container runs as, otherwise every ingest 500s.
|
|
||||||
|
|
||||||
## Workstation agent
|
|
||||||
|
|
||||||
`agent/` holds the zero-dependency collector that runs from cron on a
|
|
||||||
workstation and feeds `POST /hosts/<id>`. It is not part of the server image —
|
|
||||||
it is installed by copying files onto the machine it measures. Install steps,
|
|
||||||
crontab line and failure modes: [`agent/README.md`](agent/README.md).
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```
|
|
||||||
npm install
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
@ -1,597 +0,0 @@
|
||||||
const http = require("node:http");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const os = require("node:os");
|
|
||||||
const path = require("node:path");
|
|
||||||
const { execFile } = require("node:child_process");
|
|
||||||
const { promisify } = require("node:util");
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
const {
|
|
||||||
buildSnapshot,
|
|
||||||
buildTargetUrl,
|
|
||||||
postSnapshot,
|
|
||||||
readConfig,
|
|
||||||
DEFAULT_TIMEOUT_MS,
|
|
||||||
EXIT_CONFIG,
|
|
||||||
EXIT_PUSH,
|
|
||||||
} = require("../agent/push-metrics.js");
|
|
||||||
const { collectMetrics } = require("../metrics.js");
|
|
||||||
const { sanitizeSnapshot } = require("../index.js");
|
|
||||||
|
|
||||||
// Coverage for the local workstation agent (issue #15).
|
|
||||||
//
|
|
||||||
// Three things are worth stating up front, because they explain why this file
|
|
||||||
// spends most of its length on failure paths rather than on the happy one.
|
|
||||||
//
|
|
||||||
// * THE TOKEN MUST NOT SURVIVE INTO A LOG LINE. run-push.sh pipes the agent's
|
|
||||||
// output into `logger -t host-agent`; anything printed lands in syslog and
|
|
||||||
// journald for good. Every failure path here is therefore checked against
|
|
||||||
// every 5-character-or-longer fragment of the token — message, stack, JSON
|
|
||||||
// rendering — and the two spawned-process cases check the real stdout and
|
|
||||||
// stderr of a real run, which is what `logger` would actually swallow.
|
|
||||||
// * NO QUEUE, NO REPLAY. A failed push is dropped, not retried: the
|
|
||||||
// assertions count the requests the server saw and expect exactly one.
|
|
||||||
// * The payload contract is pinned against the server's own
|
|
||||||
// sanitizeSnapshot(), and one case pushes a REAL collectMetrics() snapshot
|
|
||||||
// through the REAL ingestion handler. A drift between agent and server
|
|
||||||
// shows up as a red test rather than as a 400 at 3 a.m. on the ThinkPad.
|
|
||||||
|
|
||||||
const AGENT_PATH = path.join(__dirname, "..", "agent", "push-metrics.js");
|
|
||||||
const RUNNER_PATH = path.join(__dirname, "..", "agent", "run-push.sh");
|
|
||||||
|
|
||||||
// Mixed letters and digits, no dictionary word and no run of five digits, so a
|
|
||||||
// port number or a timestamp in the output can never look like a leak.
|
|
||||||
const TOKEN = "7f2a9c4e1b6d8035a5e0c3d1";
|
|
||||||
const HOST_ID = "thinkpad";
|
|
||||||
|
|
||||||
const servers = [];
|
|
||||||
let tmpDir;
|
|
||||||
|
|
||||||
function fakeMetrics() {
|
|
||||||
return {
|
|
||||||
cpu: {
|
|
||||||
model: "Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz",
|
|
||||||
cores: 8,
|
|
||||||
loadAvg: [0.42, 0.31, 0.25],
|
|
||||||
usagePercent: 12,
|
|
||||||
},
|
|
||||||
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
|
|
||||||
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startServer(onRequest) {
|
|
||||||
const server = http.createServer(onRequest);
|
|
||||||
servers.push(server);
|
|
||||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
||||||
return { server, url: `http://127.0.0.1:${server.address().port}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// A recording server that answers with the given status. Returns the log of
|
|
||||||
// what it saw, so "exactly one request" and "the Authorization header was sent"
|
|
||||||
// are assertions rather than assumptions.
|
|
||||||
async function startRecordingServer(status = 204) {
|
|
||||||
const seen = [];
|
|
||||||
const { url, server } = await startServer((req, res) => {
|
|
||||||
const chunks = [];
|
|
||||||
req.on("data", (c) => chunks.push(c));
|
|
||||||
req.on("end", () => {
|
|
||||||
seen.push({
|
|
||||||
method: req.method,
|
|
||||||
url: req.url,
|
|
||||||
headers: req.headers,
|
|
||||||
body: Buffer.concat(chunks).toString("utf-8"),
|
|
||||||
});
|
|
||||||
res.writeHead(status);
|
|
||||||
res.end();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return { url, server, seen };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closeServers() {
|
|
||||||
while (servers.length) {
|
|
||||||
const server = servers.pop();
|
|
||||||
server.closeAllConnections?.();
|
|
||||||
await new Promise((resolve) => server.close(resolve));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every substring of the token from `min` characters up. A failure message that
|
|
||||||
// contains none of them cannot contain the token, nor a truncated half of it.
|
|
||||||
function tokenFragments(token, min = 5) {
|
|
||||||
const out = [];
|
|
||||||
for (let len = min; len <= token.length; len++) {
|
|
||||||
for (let i = 0; i + len <= token.length; i++) out.push(token.slice(i, i + len));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function expectNoTokenLeak(text, token = TOKEN) {
|
|
||||||
const haystack = String(text);
|
|
||||||
const leaked = tokenFragments(token).filter((frag) => haystack.includes(frag));
|
|
||||||
// Report the longest match only: every shorter fragment of it also matches,
|
|
||||||
// and dumping the 200-odd of them buries the failure it is reporting.
|
|
||||||
const worst = leaked.reduce((a, b) => (b.length > a.length ? b : a), "");
|
|
||||||
expect(
|
|
||||||
leaked.length,
|
|
||||||
`token leaked into the output — longest fragment found: "${worst}"`,
|
|
||||||
).toBe(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything a careless `console.error(err)` would print.
|
|
||||||
function renderError(err) {
|
|
||||||
return [
|
|
||||||
err.message,
|
|
||||||
String(err),
|
|
||||||
err.stack,
|
|
||||||
JSON.stringify(err),
|
|
||||||
JSON.stringify(err, Object.getOwnPropertyNames(err)),
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pushTo(url, overrides = {}) {
|
|
||||||
return postSnapshot({
|
|
||||||
apiUrl: url,
|
|
||||||
token: TOKEN,
|
|
||||||
hostId: HOST_ID,
|
|
||||||
snapshot: buildSnapshot(fakeMetrics()),
|
|
||||||
timeoutMs: 2000,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-test-"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await closeServers();
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("payload — matches the POST /hosts/<id> contract", () => {
|
|
||||||
test("the built snapshot passes the server's sanitizeSnapshot() unchanged", () => {
|
|
||||||
const snapshot = buildSnapshot(fakeMetrics());
|
|
||||||
const result = sanitizeSnapshot(snapshot);
|
|
||||||
expect(result.error).toBeUndefined();
|
|
||||||
expect(result.snapshot).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("exactly the documented keys, nothing more", () => {
|
|
||||||
const snapshot = buildSnapshot(fakeMetrics());
|
|
||||||
expect(Object.keys(snapshot).sort()).toEqual([
|
|
||||||
"cpu",
|
|
||||||
"disk",
|
|
||||||
"hostname",
|
|
||||||
"memory",
|
|
||||||
"uptime",
|
|
||||||
]);
|
|
||||||
expect(Object.keys(snapshot.cpu).sort()).toEqual([
|
|
||||||
"cores",
|
|
||||||
"loadAvg",
|
|
||||||
"model",
|
|
||||||
"usagePercent",
|
|
||||||
]);
|
|
||||||
for (const key of ["memory", "disk"]) {
|
|
||||||
expect(Object.keys(snapshot[key]).sort()).toEqual([
|
|
||||||
"freeGB",
|
|
||||||
"totalGB",
|
|
||||||
"usagePercent",
|
|
||||||
"usedGB",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an extra field added to collectMetrics() does not leave the workstation", () => {
|
|
||||||
const metrics = fakeMetrics();
|
|
||||||
metrics.temperatureC = 61;
|
|
||||||
metrics.cpu.serial = "PF0X1234";
|
|
||||||
const snapshot = buildSnapshot(metrics);
|
|
||||||
expect(snapshot.temperatureC).toBeUndefined();
|
|
||||||
expect(snapshot.cpu.serial).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hostname and uptime come from the host, uptime floored like getHealth()", () => {
|
|
||||||
const snapshot = buildSnapshot(fakeMetrics());
|
|
||||||
expect(snapshot.hostname).toBe(os.hostname());
|
|
||||||
expect(Number.isInteger(snapshot.uptime)).toBe(true);
|
|
||||||
expect(snapshot.uptime).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a real collectMetrics() snapshot is accepted by sanitizeSnapshot()", async () => {
|
|
||||||
const snapshot = buildSnapshot(await collectMetrics());
|
|
||||||
const result = sanitizeSnapshot(snapshot);
|
|
||||||
expect(result.error).toBeUndefined();
|
|
||||||
expect(result.snapshot).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("delivery — one attempt, no queue", () => {
|
|
||||||
test("POSTs to /hosts/<id> with the bearer token and resolves on 204", async () => {
|
|
||||||
const { url, seen } = await startRecordingServer(204);
|
|
||||||
// Build ONCE and compare against that object. Two calls to buildSnapshot()
|
|
||||||
// straddling a second boundary differ by 1 on `uptime`, which turns this
|
|
||||||
// assertion into a one-in-several-runs failure.
|
|
||||||
const snapshot = buildSnapshot(fakeMetrics());
|
|
||||||
const result = await pushTo(url, { snapshot });
|
|
||||||
|
|
||||||
expect(result.statusCode).toBe(204);
|
|
||||||
expect(seen).toHaveLength(1);
|
|
||||||
expect(seen[0].method).toBe("POST");
|
|
||||||
expect(seen[0].url).toBe(`/hosts/${HOST_ID}`);
|
|
||||||
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
|
|
||||||
expect(seen[0].headers["content-type"]).toBe("application/json");
|
|
||||||
expect(JSON.parse(seen[0].body)).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("any 2xx counts as delivered", async () => {
|
|
||||||
const { url } = await startRecordingServer(200);
|
|
||||||
await expect(pushTo(url)).resolves.toEqual({ statusCode: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([400, 401, 403, 413, 500, 503])("rejects on HTTP %i", async (status) => {
|
|
||||||
const { url } = await startRecordingServer(status);
|
|
||||||
await expect(pushTo(url)).rejects.toThrow(`push failed: HTTP ${status}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a rejected push is dropped, never retried", async () => {
|
|
||||||
const { url, seen } = await startRecordingServer(500);
|
|
||||||
await expect(pushTo(url)).rejects.toThrow();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
||||||
expect(seen).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reports only err.code when the connection is refused", async () => {
|
|
||||||
const { url, server } = await startRecordingServer();
|
|
||||||
await new Promise((resolve) => server.close(resolve));
|
|
||||||
servers.pop();
|
|
||||||
await expect(pushTo(url)).rejects.toThrow("push failed: request error (code=ECONNREFUSED)");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gives up on a server that never answers, as ETIMEDOUT", async () => {
|
|
||||||
const { url } = await startServer(() => {
|
|
||||||
/* never responds */
|
|
||||||
});
|
|
||||||
const started = Date.now();
|
|
||||||
await expect(pushTo(url, { timeoutMs: 200 })).rejects.toThrow(
|
|
||||||
"push failed: request error (code=ETIMEDOUT)",
|
|
||||||
);
|
|
||||||
expect(Date.now() - started).toBeLessThan(2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the default timeout is 10s", () => {
|
|
||||||
expect(DEFAULT_TIMEOUT_MS).toBe(10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a trailing slash on HOSTS_API_URL does not double up", () => {
|
|
||||||
expect(buildTargetUrl("https://health.example.com/", "thinkpad").href).toBe(
|
|
||||||
"https://health.example.com/hosts/thinkpad",
|
|
||||||
);
|
|
||||||
expect(buildTargetUrl("https://health.example.com", "thinkpad").href).toBe(
|
|
||||||
"https://health.example.com/hosts/thinkpad",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
|
|
||||||
["not a url", "HOSTS_API_URL is not a valid URL"],
|
|
||||||
])("refuses %s before opening a socket", async (apiUrl, message) => {
|
|
||||||
await expect(pushTo(apiUrl)).rejects.toThrow(message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the failure path never carries the token", () => {
|
|
||||||
test("HTTP failure: message, stack and JSON rendering are all clean", async () => {
|
|
||||||
const { url } = await startRecordingServer(500);
|
|
||||||
const err = await pushTo(url).catch((e) => e);
|
|
||||||
expect(err.message).toBe("push failed: HTTP 500");
|
|
||||||
expectNoTokenLeak(renderError(err));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("transport failure: message, stack and JSON rendering are all clean", async () => {
|
|
||||||
const { url, server } = await startRecordingServer();
|
|
||||||
await new Promise((resolve) => server.close(resolve));
|
|
||||||
servers.pop();
|
|
||||||
const err = await pushTo(url).catch((e) => e);
|
|
||||||
expectNoTokenLeak(renderError(err));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("timeout failure: message, stack and JSON rendering are all clean", async () => {
|
|
||||||
const { url } = await startServer(() => {});
|
|
||||||
const err = await pushTo(url, { timeoutMs: 200 }).catch((e) => e);
|
|
||||||
expectNoTokenLeak(renderError(err));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a token with a trailing newline fails without quoting itself", async () => {
|
|
||||||
const { url } = await startRecordingServer(204);
|
|
||||||
const err = await pushTo(url, { token: `${TOKEN}\n` }).catch((e) => e);
|
|
||||||
expect(err.message).toContain("HOSTS_INGEST_TOKEN contains an invalid character");
|
|
||||||
expectNoTokenLeak(renderError(err));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the real process output of a failing run contains no fragment of the token", async () => {
|
|
||||||
const { url } = await startRecordingServer(500);
|
|
||||||
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
|
|
||||||
env: {
|
|
||||||
PATH: process.env.PATH,
|
|
||||||
HOSTS_API_URL: url,
|
|
||||||
HOSTS_INGEST_TOKEN: TOKEN,
|
|
||||||
HOST_ID,
|
|
||||||
},
|
|
||||||
}).catch((e) => e);
|
|
||||||
|
|
||||||
expect(result.code).toBe(EXIT_PUSH);
|
|
||||||
expect(result.stderr).toContain("host-agent: push failed: HTTP 500");
|
|
||||||
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the real process output of an unreachable server contains no fragment of the token", async () => {
|
|
||||||
const { url, server } = await startRecordingServer();
|
|
||||||
await new Promise((resolve) => server.close(resolve));
|
|
||||||
servers.pop();
|
|
||||||
|
|
||||||
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
|
|
||||||
env: {
|
|
||||||
PATH: process.env.PATH,
|
|
||||||
HOSTS_API_URL: url,
|
|
||||||
HOSTS_INGEST_TOKEN: TOKEN,
|
|
||||||
HOST_ID,
|
|
||||||
},
|
|
||||||
}).catch((e) => e);
|
|
||||||
|
|
||||||
expect(result.code).toBe(EXIT_PUSH);
|
|
||||||
expect(result.stderr).toContain("code=ECONNREFUSED");
|
|
||||||
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("configuration", () => {
|
|
||||||
test.each([
|
|
||||||
["HOSTS_API_URL", "HOSTS_API_URL is not set"],
|
|
||||||
["HOSTS_INGEST_TOKEN", "HOSTS_INGEST_TOKEN is not set"],
|
|
||||||
["HOST_ID", "HOST_ID is not set"],
|
|
||||||
])("a missing %s is named, not guessed", (missing, message) => {
|
|
||||||
const env = { HOSTS_API_URL: "https://x.test", HOSTS_INGEST_TOKEN: TOKEN, HOST_ID };
|
|
||||||
delete env[missing];
|
|
||||||
const { error, config } = readConfig(env);
|
|
||||||
expect(config).toBeUndefined();
|
|
||||||
expect(error.message).toContain(message);
|
|
||||||
expectNoTokenLeak(renderError(error));
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each(["THINKPAD", "-thinkpad", "think pad", "a".repeat(33), "../thinkpad"])(
|
|
||||||
"rejects the malformed HOST_ID %j client-side",
|
|
||||||
(hostId) => {
|
|
||||||
const { error } = readConfig({
|
|
||||||
HOSTS_API_URL: "https://x.test",
|
|
||||||
HOSTS_INGEST_TOKEN: TOKEN,
|
|
||||||
HOST_ID: hostId,
|
|
||||||
});
|
|
||||||
expect(error.message).toContain("HOST_ID must match");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
|
|
||||||
["not a url", "HOSTS_API_URL is not a valid URL"],
|
|
||||||
])("a broken HOSTS_API_URL (%j) is an install error, not a push error", (apiUrl, message) => {
|
|
||||||
const { error, config } = readConfig({
|
|
||||||
HOSTS_API_URL: apiUrl,
|
|
||||||
HOSTS_INGEST_TOKEN: TOKEN,
|
|
||||||
HOST_ID,
|
|
||||||
});
|
|
||||||
expect(config).toBeUndefined();
|
|
||||||
expect(error.message).toContain(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts a well-formed config and defaults the timeout", () => {
|
|
||||||
const { config } = readConfig({
|
|
||||||
HOSTS_API_URL: "https://health.example.com",
|
|
||||||
HOSTS_INGEST_TOKEN: TOKEN,
|
|
||||||
HOST_ID,
|
|
||||||
});
|
|
||||||
expect(config).toEqual({
|
|
||||||
apiUrl: "https://health.example.com",
|
|
||||||
token: TOKEN,
|
|
||||||
hostId: HOST_ID,
|
|
||||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("--dry-run prints the payload and touches no network", async () => {
|
|
||||||
const { stdout } = await execFileAsync(process.execPath, [AGENT_PATH, "--dry-run"], {
|
|
||||||
env: { PATH: process.env.PATH },
|
|
||||||
});
|
|
||||||
const snapshot = JSON.parse(stdout);
|
|
||||||
expect(sanitizeSnapshot(snapshot).error).toBeUndefined();
|
|
||||||
expect(snapshot.hostname).toBe(os.hostname());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("run-push.sh", () => {
|
|
||||||
function writeEnvFile(lines, mode = 0o600) {
|
|
||||||
const file = path.join(tmpDir, "maximus-host-agent.env");
|
|
||||||
fs.writeFileSync(file, `${lines.join("\n")}\n`, { mode });
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runWrapper(envFile) {
|
|
||||||
return execFileAsync(RUNNER_PATH, [], {
|
|
||||||
env: { PATH: process.env.PATH, HOME: tmpDir, HOST_AGENT_ENV_FILE: envFile },
|
|
||||||
}).catch((e) => e);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("delivers the snapshot end to end", async () => {
|
|
||||||
const { url, seen } = await startRecordingServer(204);
|
|
||||||
const envFile = writeEnvFile([
|
|
||||||
`HOSTS_API_URL=${url}`,
|
|
||||||
`HOSTS_INGEST_TOKEN=${TOKEN}`,
|
|
||||||
`HOST_ID=${HOST_ID}`,
|
|
||||||
`NODE_BIN=${process.execPath}`,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await runWrapper(envFile);
|
|
||||||
|
|
||||||
expect(result.stderr).toBe("");
|
|
||||||
expect(result.stdout).toContain(`host-agent: ok id=${HOST_ID} status=204`);
|
|
||||||
expect(seen).toHaveLength(1);
|
|
||||||
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
|
|
||||||
expect(sanitizeSnapshot(JSON.parse(seen[0].body)).error).toBeUndefined();
|
|
||||||
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
[["HOSTS_INGEST_TOKEN", "HOST_ID"], "HOSTS_API_URL is not set"],
|
|
||||||
[["HOSTS_API_URL", "HOST_ID"], "HOSTS_INGEST_TOKEN is not set"],
|
|
||||||
[["HOSTS_API_URL", "HOSTS_INGEST_TOKEN"], "HOST_ID is not set"],
|
|
||||||
])("stops when a variable is missing (%j)", async (present, message) => {
|
|
||||||
const values = {
|
|
||||||
HOSTS_API_URL: "https://health.example.com",
|
|
||||||
HOSTS_INGEST_TOKEN: TOKEN,
|
|
||||||
HOST_ID,
|
|
||||||
};
|
|
||||||
const envFile = writeEnvFile(present.map((key) => `${key}=${values[key]}`));
|
|
||||||
|
|
||||||
const result = await runWrapper(envFile);
|
|
||||||
|
|
||||||
expect(result.code).toBe(1);
|
|
||||||
expect(result.stderr).toContain(message);
|
|
||||||
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stops when the env file is absent", async () => {
|
|
||||||
const missing = path.join(tmpDir, "nope.env");
|
|
||||||
const result = await runWrapper(missing);
|
|
||||||
expect(result.code).toBe(1);
|
|
||||||
expect(result.stderr).toContain(`host-agent: env file not found: ${missing}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("warns about a world-readable env file but still runs", async () => {
|
|
||||||
const { url, seen } = await startRecordingServer(204);
|
|
||||||
const envFile = writeEnvFile(
|
|
||||||
[
|
|
||||||
`HOSTS_API_URL=${url}`,
|
|
||||||
`HOSTS_INGEST_TOKEN=${TOKEN}`,
|
|
||||||
`HOST_ID=${HOST_ID}`,
|
|
||||||
`NODE_BIN=${process.execPath}`,
|
|
||||||
],
|
|
||||||
0o644,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await runWrapper(envFile);
|
|
||||||
|
|
||||||
expect(result.stderr).toContain("is mode 644, expected 600");
|
|
||||||
expect(seen).toHaveLength(1);
|
|
||||||
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("never enables shell tracing — `set -x` would echo the token into syslog", () => {
|
|
||||||
const script = fs.readFileSync(RUNNER_PATH, "utf-8");
|
|
||||||
const traced = script
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => !line.trim().startsWith("#"))
|
|
||||||
.filter((line) => /\bset\b[^#\n]*-[a-z]*x/.test(line));
|
|
||||||
expect(traced).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("is executable", () => {
|
|
||||||
expect(fs.statSync(RUNNER_PATH).mode & 0o111).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("the agent stays out of the server image", () => {
|
|
||||||
test("the Dockerfile copies files one by one and none of them is the agent", () => {
|
|
||||||
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
|
|
||||||
const copies = dockerfile
|
|
||||||
.split("\n")
|
|
||||||
.filter((line) => /^\s*(COPY|ADD)\b/i.test(line));
|
|
||||||
|
|
||||||
expect(copies).toHaveLength(1);
|
|
||||||
expect(copies[0]).not.toMatch(/agent/);
|
|
||||||
// A bare `COPY . .` would drag agent/ in without ever naming it.
|
|
||||||
expect(copies[0]).not.toMatch(/^\s*COPY\s+\.\s/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("against the real ingestion handler", () => {
|
|
||||||
const savedEnv = {};
|
|
||||||
const ENV_KEYS = ["HOSTS_INGEST_TOKEN", "HOSTS_DIR", "HOSTS_ALLOWED_IDS", "HEALTH_TOKEN"];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
|
|
||||||
process.env.HOSTS_INGEST_TOKEN = TOKEN;
|
|
||||||
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
|
|
||||||
process.env.HOSTS_ALLOWED_IDS = HOST_ID;
|
|
||||||
process.env.HEALTH_TOKEN = "read-token";
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const key of ENV_KEYS) {
|
|
||||||
if (savedEnv[key] === undefined) delete process.env[key];
|
|
||||||
else process.env[key] = savedEnv[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a real snapshot is accepted with 204 and persisted field for field", async () => {
|
|
||||||
// Fresh import so index.js picks up the env above.
|
|
||||||
delete require.cache[require.resolve("../index.js")];
|
|
||||||
const { handler } = require("../index.js");
|
|
||||||
const { url } = await startServer(handler);
|
|
||||||
|
|
||||||
const snapshot = buildSnapshot(await collectMetrics());
|
|
||||||
const result = await postSnapshot({
|
|
||||||
apiUrl: url,
|
|
||||||
token: TOKEN,
|
|
||||||
hostId: HOST_ID,
|
|
||||||
snapshot,
|
|
||||||
timeoutMs: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.statusCode).toBe(204);
|
|
||||||
|
|
||||||
const written = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(process.env.HOSTS_DIR, `${HOST_ID}.json`), "utf-8"),
|
|
||||||
);
|
|
||||||
expect(written.id).toBe(HOST_ID);
|
|
||||||
expect(typeof written.receivedAt).toBe("string");
|
|
||||||
delete written.id;
|
|
||||||
delete written.receivedAt;
|
|
||||||
expect(written).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a wrong ingest token is rejected with 401 and no fragment leaks", async () => {
|
|
||||||
delete require.cache[require.resolve("../index.js")];
|
|
||||||
const { handler } = require("../index.js");
|
|
||||||
const { url } = await startServer(handler);
|
|
||||||
|
|
||||||
const err = await postSnapshot({
|
|
||||||
apiUrl: url,
|
|
||||||
token: "wrong-token",
|
|
||||||
hostId: HOST_ID,
|
|
||||||
snapshot: buildSnapshot(fakeMetrics()),
|
|
||||||
timeoutMs: 5000,
|
|
||||||
}).catch((e) => e);
|
|
||||||
|
|
||||||
expect(err.message).toBe("push failed: HTTP 401");
|
|
||||||
expectNoTokenLeak(renderError(err));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a host id outside the allowlist surfaces as HTTP 403", async () => {
|
|
||||||
process.env.HOSTS_ALLOWED_IDS = "someone-else";
|
|
||||||
delete require.cache[require.resolve("../index.js")];
|
|
||||||
const { handler } = require("../index.js");
|
|
||||||
const { url } = await startServer(handler);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
postSnapshot({
|
|
||||||
apiUrl: url,
|
|
||||||
token: TOKEN,
|
|
||||||
hostId: HOST_ID,
|
|
||||||
snapshot: buildSnapshot(fakeMetrics()),
|
|
||||||
timeoutMs: 5000,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("push failed: HTTP 403");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
const http = require("node:http");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const os = require("node:os");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
// Authentication safety net for the four read routes.
|
|
||||||
//
|
|
||||||
// findings.test.js and health.test.js cover what each route *returns*; this
|
|
||||||
// file covers the gate in front of all of them. The small overlap on
|
|
||||||
// /defenseurs/findings and /health is deliberate: the value of this file is the
|
|
||||||
// complete route x failure-mode matrix in one place, so a routing/auth refactor
|
|
||||||
// that only rewires part of the table still fails loudly here.
|
|
||||||
//
|
|
||||||
// These tests describe the CURRENT behaviour of index.js. If one of them turns
|
|
||||||
// red after a refactor, the refactor changed the security contract.
|
|
||||||
|
|
||||||
const TOKEN = "test-token";
|
|
||||||
|
|
||||||
// Every GET route reachable by the handler. Any new route must be added here.
|
|
||||||
// POST /hosts/<id> is not a GET, so it gets its own describe block below —
|
|
||||||
// but it is covered, and any future route must be too.
|
|
||||||
const ROUTES = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans", "/hosts"];
|
|
||||||
|
|
||||||
// Ingest token, distinct from the read token on purpose: a read token must not
|
|
||||||
// open the write path, and a write token must not open the read routes.
|
|
||||||
const INGEST_TOKEN = "test-ingest-token";
|
|
||||||
|
|
||||||
let tmpDir;
|
|
||||||
let server;
|
|
||||||
let baseUrl;
|
|
||||||
let handler;
|
|
||||||
|
|
||||||
function startServer() {
|
|
||||||
// Fresh import so module-level constants pick up the stubbed env.
|
|
||||||
delete require.cache[require.resolve("../index.js")];
|
|
||||||
({ handler } = require("../index.js"));
|
|
||||||
server = http.createServer(handler);
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
server.listen(0, () => {
|
|
||||||
const { port } = server.address();
|
|
||||||
baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopServer() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!server) return resolve();
|
|
||||||
server.close(() => resolve());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restartServer() {
|
|
||||||
await stopServer();
|
|
||||||
await startServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function request(path, { method = "GET", auth = `Bearer ${TOKEN}` } = {}) {
|
|
||||||
const headers = {};
|
|
||||||
if (auth) headers.Authorization = auth;
|
|
||||||
const res = await fetch(`${baseUrl}${path}`, { method, headers });
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
return { status: res.status, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-auth-test-"));
|
|
||||||
|
|
||||||
process.env.HEALTH_TOKEN = TOKEN;
|
|
||||||
// Point every filesystem read at the temp dir so a rejected request can never
|
|
||||||
// fall back to the host's real /data/defenseurs.
|
|
||||||
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
|
|
||||||
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
|
|
||||||
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
|
|
||||||
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
|
|
||||||
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
|
|
||||||
// Configured on purpose: an unset ingest token answers 503 before the header
|
|
||||||
// is ever read, which would hide the 401 these tests are here to pin.
|
|
||||||
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
|
|
||||||
// Closed port: /health must never reach the real IdP. If the auth gate ever
|
|
||||||
// fails open, the request errors out fast instead of hitting production.
|
|
||||||
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
|
|
||||||
|
|
||||||
await startServer();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await stopServer();
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("auth gate — missing Authorization header", () => {
|
|
||||||
test.each(ROUTES)("401 on GET %s with no Authorization header", async (route) => {
|
|
||||||
const { status, body } = await request(route, { auth: null });
|
|
||||||
expect(status).toBe(401);
|
|
||||||
// Pin the gate itself: auth runs before any query-param validation, so
|
|
||||||
// /defenseurs/findings and /reports/scans must answer 401, never 400.
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("auth gate — invalid token", () => {
|
|
||||||
test.each(ROUTES)("401 on GET %s with a wrong bearer token", async (route) => {
|
|
||||||
const { status, body } = await request(route, { auth: "Bearer wrong-token" });
|
|
||||||
expect(status).toBe(401);
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("auth gate — malformed Authorization header", () => {
|
|
||||||
// The header is compared byte for byte against `Bearer <token>`, so these
|
|
||||||
// near-misses are all rejected. /defenseurs stands in for the four routes:
|
|
||||||
// it serves the parc-wide Defenseurs report and is the costliest to leak.
|
|
||||||
const MALFORMED = [
|
|
||||||
["raw token without the Bearer scheme", TOKEN],
|
|
||||||
["lowercase scheme", `bearer ${TOKEN}`],
|
|
||||||
["scheme with no token", "Bearer"],
|
|
||||||
];
|
|
||||||
|
|
||||||
test.each(MALFORMED)("401 on GET /defenseurs — %s", async (_label, header) => {
|
|
||||||
const { status, body } = await request("/defenseurs", { auth: header });
|
|
||||||
expect(status).toBe(401);
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("auth gate — fail-closed when HEALTH_TOKEN is unset", () => {
|
|
||||||
test("401 on GET /defenseurs even with a well-formed bearer token", async () => {
|
|
||||||
delete process.env.HEALTH_TOKEN;
|
|
||||||
await restartServer();
|
|
||||||
const { status, body } = await request("/defenseurs", { auth: `Bearer ${TOKEN}` });
|
|
||||||
expect(status).toBe(401);
|
|
||||||
expect(body.error).toBe("HEALTH_TOKEN not configured");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("routing", () => {
|
|
||||||
test("404 on an unknown route", async () => {
|
|
||||||
const { status, body } = await request("/nope");
|
|
||||||
expect(status).toBe(404);
|
|
||||||
expect(body.error).toBe("Not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each(ROUTES)("404 on POST %s (method not allowed)", async (route) => {
|
|
||||||
const { status, body } = await request(route, { method: "POST" });
|
|
||||||
expect(status).toBe(404);
|
|
||||||
expect(body.error).toBe("Not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Current ordering: the route/method check runs BEFORE authentication, so an
|
|
||||||
// unauthenticated caller gets 404 rather than 401 on these. Documented as-is;
|
|
||||||
// any refactor that flips the order will turn these red on purpose.
|
|
||||||
test("404 (not 401) on an unknown route without Authorization", async () => {
|
|
||||||
const { status, body } = await request("/nope", { auth: null });
|
|
||||||
expect(status).toBe(404);
|
|
||||||
expect(body.error).toBe("Not found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("404 (not 401) on POST /health without Authorization", async () => {
|
|
||||||
const { status, body } = await request("/health", { method: "POST", auth: null });
|
|
||||||
expect(status).toBe(404);
|
|
||||||
expect(body.error).toBe("Not found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// The ingest path is the only write surface on this service, and the only one
|
|
||||||
// reachable with a token that is NOT the read token. Both directions of that
|
|
||||||
// separation are pinned here: a read token must not write, a write token must
|
|
||||||
// not read. The gate answers before the request body is ever buffered, so an
|
|
||||||
// unauthenticated caller cannot make the server hold 4 KiB on its behalf.
|
|
||||||
describe("auth gate — ingest route", () => {
|
|
||||||
test("401 on POST /hosts/<id> with no Authorization header", async () => {
|
|
||||||
const { status, body } = await request("/hosts/thinkpad", { method: "POST", auth: null });
|
|
||||||
expect(status).toBe(401);
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("401 on POST /hosts/<id> with the read token", async () => {
|
|
||||||
const { status, body } = await request("/hosts/thinkpad", {
|
|
||||||
method: "POST",
|
|
||||||
auth: `Bearer ${TOKEN}`,
|
|
||||||
});
|
|
||||||
expect(status).toBe(401);
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("401 on GET /hosts with the ingest token", async () => {
|
|
||||||
const { status, body } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` });
|
|
||||||
expect(status).toBe(401);
|
|
||||||
expect(body.error).toBe("Unauthorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
// An unknown host id never reaches the handler: the route regex is the
|
|
||||||
// path-traversal control, so a malformed id is a 404, not a 403 — and that
|
|
||||||
// 404 arrives before authentication, like every other routing decision.
|
|
||||||
test("404 (not 401, not 403) on POST /hosts/../../etc/passwd", async () => {
|
|
||||||
const { status } = await request("/hosts/../../etc/passwd", { method: "POST", auth: null });
|
|
||||||
expect(status).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
const http = require("node:http");
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const os = require("node:os");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
const TOKEN = "test-token";
|
|
||||||
|
|
||||||
function makeFinding(id, severity, category) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
severity,
|
|
||||||
category,
|
|
||||||
title: `${id} title`,
|
|
||||||
description: `${id} desc`,
|
|
||||||
location: `${id}/loc`,
|
|
||||||
recommendation: `${id} reco`,
|
|
||||||
firstSeen: "2026-05-01T00:00:00.000Z",
|
|
||||||
status: "open",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_FINDINGS = [
|
|
||||||
makeFinding("c1", "CRITICAL", "deps"),
|
|
||||||
makeFinding("h1", "HIGH", "secrets"),
|
|
||||||
makeFinding("m1", "MEDIUM", "deps"),
|
|
||||||
makeFinding("l1", "LOW", "code"),
|
|
||||||
makeFinding("i1", "INFO", "infra"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let tmpDir;
|
|
||||||
let reportsDir;
|
|
||||||
let agentsMapPath;
|
|
||||||
let server;
|
|
||||||
let baseUrl;
|
|
||||||
let handler;
|
|
||||||
|
|
||||||
function writeReport(agent, timestamp, findings, subdir = "") {
|
|
||||||
const dir = subdir ? path.join(reportsDir, subdir) : reportsDir;
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
const safeTs = timestamp.replace(/[:.]/g, "-");
|
|
||||||
const file = path.join(dir, `defenseur-${agent}_${safeTs}.json`);
|
|
||||||
fs.writeFileSync(
|
|
||||||
file,
|
|
||||||
JSON.stringify({ agent: `defenseur-${agent}`, timestamp, findings }),
|
|
||||||
);
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeAgentsMap(map) {
|
|
||||||
fs.writeFileSync(agentsMapPath, JSON.stringify(map));
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeRawAgentsMap(raw) {
|
|
||||||
fs.writeFileSync(agentsMapPath, raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startServer() {
|
|
||||||
// Fresh import so module-level constants pick up the stubbed env.
|
|
||||||
delete require.cache[require.resolve("../index.js")];
|
|
||||||
({ handler } = require("../index.js"));
|
|
||||||
server = http.createServer(handler);
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
server.listen(0, () => {
|
|
||||||
const { port } = server.address();
|
|
||||||
baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopServer() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!server) return resolve();
|
|
||||||
server.close(() => resolve());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get(path, { auth = `Bearer ${TOKEN}` } = {}) {
|
|
||||||
const headers = {};
|
|
||||||
if (auth) headers.Authorization = auth;
|
|
||||||
const res = await fetch(`${baseUrl}${path}`, { headers });
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
return { status: res.status, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-test-"));
|
|
||||||
reportsDir = path.join(tmpDir, "reports");
|
|
||||||
fs.mkdirSync(reportsDir, { recursive: true });
|
|
||||||
agentsMapPath = path.join(tmpDir, "agents-map.json");
|
|
||||||
|
|
||||||
process.env.HEALTH_TOKEN = TOKEN;
|
|
||||||
process.env.REPORTS_DIR = reportsDir;
|
|
||||||
process.env.DEFENSEURS_AGENTS_MAP_PATH = agentsMapPath;
|
|
||||||
|
|
||||||
writeAgentsMap({ "la-suite-booking": "defenseur-booking" });
|
|
||||||
await startServer();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await stopServer();
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /defenseurs/findings", () => {
|
|
||||||
test("401 when no Authorization header", async () => {
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
|
||||||
const { status } = await get("/defenseurs/findings?project=la-suite-booking", { auth: null });
|
|
||||||
expect(status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("401 on invalid token", async () => {
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
|
||||||
const { status } = await get("/defenseurs/findings?project=la-suite-booking", { auth: "Bearer wrong" });
|
|
||||||
expect(status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("400 when project param is missing", async () => {
|
|
||||||
const { status, body } = await get("/defenseurs/findings");
|
|
||||||
expect(status).toBe(400);
|
|
||||||
expect(body.error).toMatch(/project/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("404 on unknown project", async () => {
|
|
||||||
const { status } = await get("/defenseurs/findings?project=unknown-x");
|
|
||||||
expect(status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 default (no severity) returns MEDIUM+HIGH+CRITICAL, hides LOW+INFO", async () => {
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
|
||||||
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body.agent).toBe("defenseur-booking");
|
|
||||||
expect(body.project).toBe("la-suite-booking");
|
|
||||||
expect(body.timestamp).toBe("2026-05-12T00:00:00.000Z");
|
|
||||||
expect(body.findings).toHaveLength(3);
|
|
||||||
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH", "MEDIUM"]);
|
|
||||||
expect(body.status).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 ?category=deps filters by category exact match", async () => {
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
|
||||||
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking&category=deps");
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body.findings.map((f) => f.id).sort()).toEqual(["c1", "m1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 ?severity=HIGH returns HIGH+CRITICAL only", async () => {
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
|
||||||
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=HIGH");
|
|
||||||
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 ?severity=LOW returns LOW+MEDIUM+HIGH+CRITICAL but hides INFO", async () => {
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
|
||||||
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=LOW");
|
|
||||||
expect(body.findings.map((f) => f.severity).sort()).toEqual(["CRITICAL", "HIGH", "LOW", "MEDIUM"]);
|
|
||||||
expect(body.findings.some((f) => f.severity === "INFO")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 ?severity=INFO returns INFO only", async () => {
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
|
||||||
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=INFO");
|
|
||||||
expect(body.findings.map((f) => f.severity)).toEqual(["INFO"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 ?category=deps&severity=CRITICAL returns just c1", async () => {
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", ALL_FINDINGS);
|
|
||||||
const { body } = await get("/defenseurs/findings?project=la-suite-booking&category=deps&severity=CRITICAL");
|
|
||||||
expect(body.findings.map((f) => f.id)).toEqual(["c1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 picks latest report (mixed top-level + archive)", async () => {
|
|
||||||
writeReport("booking", "2026-05-10T00:00:00.000Z", [makeFinding("old", "HIGH", "deps")], "archive");
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", [makeFinding("new", "HIGH", "deps")]);
|
|
||||||
const { body } = await get("/defenseurs/findings?project=la-suite-booking&severity=HIGH");
|
|
||||||
expect(body.timestamp).toBe("2026-05-12T00:00:00.000Z");
|
|
||||||
expect(body.findings.map((f) => f.id)).toEqual(["new"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 report present + findings:[] (scan clean) returns no status field", async () => {
|
|
||||||
writeReport("booking", "2026-05-12T00:00:00.000Z", []);
|
|
||||||
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body.findings).toEqual([]);
|
|
||||||
expect(body.status).toBeUndefined();
|
|
||||||
expect(body.agent).toBe("defenseur-booking");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 report absent returns {findings:[], status:'no_data'}", async () => {
|
|
||||||
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body).toEqual({ findings: [], status: "no_data" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("500 on corrupt agents-map.json", async () => {
|
|
||||||
writeRawAgentsMap("{not valid json");
|
|
||||||
const { status, body } = await get("/defenseurs/findings?project=la-suite-booking");
|
|
||||||
expect(status).toBe(500);
|
|
||||||
expect(body.error).toBe("Internal error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
const http = require("node:http");
|
|
||||||
const { setTimeout: delay } = require("node:timers/promises");
|
|
||||||
|
|
||||||
const { getCpuPercent, getDisk, collectMetrics } = require("../metrics.js");
|
|
||||||
|
|
||||||
const TOKEN = "test-token";
|
|
||||||
|
|
||||||
let logtoServer;
|
|
||||||
let logtoUrl;
|
|
||||||
let logtoDelayMs;
|
|
||||||
let server;
|
|
||||||
let baseUrl;
|
|
||||||
let handler;
|
|
||||||
|
|
||||||
// Stand-in for the Logto .well-known endpoint, with a settable delay so we can
|
|
||||||
// simulate a slow IdP without touching the network.
|
|
||||||
function startLogtoStub() {
|
|
||||||
logtoDelayMs = 0;
|
|
||||||
logtoServer = http.createServer(async (req, res) => {
|
|
||||||
if (logtoDelayMs > 0) await delay(logtoDelayMs);
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
|
||||||
res.end(JSON.stringify({ issuer: "http://127.0.0.1/oidc" }));
|
|
||||||
});
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
logtoServer.listen(0, "127.0.0.1", () => {
|
|
||||||
const { port } = logtoServer.address();
|
|
||||||
logtoUrl = `http://127.0.0.1:${port}/oidc/.well-known/openid-configuration`;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startServer() {
|
|
||||||
// Fresh import so module-level constants pick up the stubbed env.
|
|
||||||
delete require.cache[require.resolve("../index.js")];
|
|
||||||
({ handler } = require("../index.js"));
|
|
||||||
server = http.createServer(handler);
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
server.listen(0, () => {
|
|
||||||
const { port } = server.address();
|
|
||||||
baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function close(target) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!target) return resolve();
|
|
||||||
target.close(() => resolve());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get(path, { auth = `Bearer ${TOKEN}` } = {}) {
|
|
||||||
const headers = {};
|
|
||||||
if (auth) headers.Authorization = auth;
|
|
||||||
const res = await fetch(`${baseUrl}${path}`, { headers });
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
return { status: res.status, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await startLogtoStub();
|
|
||||||
process.env.HEALTH_TOKEN = TOKEN;
|
|
||||||
process.env.LOGTO_HEALTH_URL = logtoUrl;
|
|
||||||
await startServer();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await close(server);
|
|
||||||
await close(logtoServer);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("metrics module", () => {
|
|
||||||
test("exposes getCpuPercent, getDisk and collectMetrics", () => {
|
|
||||||
expect(typeof getCpuPercent).toBe("function");
|
|
||||||
expect(typeof getDisk).toBe("function");
|
|
||||||
expect(typeof collectMetrics).toBe("function");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getDisk returns the four numeric disk fields", () => {
|
|
||||||
const disk = getDisk();
|
|
||||||
expect(Object.keys(disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
|
|
||||||
for (const value of Object.values(disk)) expect(typeof value).toBe("number");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("collectMetrics returns the cpu/memory/disk slice of /health", async () => {
|
|
||||||
const metrics = await collectMetrics();
|
|
||||||
expect(Object.keys(metrics)).toEqual(["cpu", "memory", "disk"]);
|
|
||||||
expect(Object.keys(metrics.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
|
|
||||||
expect(Object.keys(metrics.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
|
|
||||||
expect(metrics.cpu.cores).toBeGreaterThan(0);
|
|
||||||
expect(Array.isArray(metrics.cpu.loadAvg)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /health", () => {
|
|
||||||
test("401 on invalid token", async () => {
|
|
||||||
const { status } = await get("/health", { auth: "Bearer wrong" });
|
|
||||||
expect(status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("200 returns the documented payload, field for field", async () => {
|
|
||||||
const { status, body } = await get("/health");
|
|
||||||
expect(status).toBe(200);
|
|
||||||
// Key order is part of the contract consumed by the admin dashboard.
|
|
||||||
expect(Object.keys(body)).toEqual([
|
|
||||||
"timestamp",
|
|
||||||
"hostname",
|
|
||||||
"uptime",
|
|
||||||
"cpu",
|
|
||||||
"memory",
|
|
||||||
"disk",
|
|
||||||
"logto",
|
|
||||||
]);
|
|
||||||
expect(Object.keys(body.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
|
|
||||||
expect(Object.keys(body.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
|
|
||||||
expect(Object.keys(body.disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
|
|
||||||
expect(body.logto.status).toBe("up");
|
|
||||||
expect(typeof body.logto.responseTimeMs).toBe("number");
|
|
||||||
expect(typeof body.uptime).toBe("number");
|
|
||||||
expect(typeof body.hostname).toBe("string");
|
|
||||||
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Latency regression guard. Metrics collection (500ms CPU sample) and the
|
|
||||||
// Logto check must run concurrently: serializing them would make /health take
|
|
||||||
// 500ms + the Logto response time. With a 1200ms Logto, concurrent lands at
|
|
||||||
// ~1200ms while serialized lands at ~1700ms — only the former clears 1.5s.
|
|
||||||
test("stays under 1.5s with a slow Logto (metrics stay concurrent)", async () => {
|
|
||||||
logtoDelayMs = 1200;
|
|
||||||
const start = performance.now();
|
|
||||||
const { status, body } = await get("/health");
|
|
||||||
const elapsed = performance.now() - start;
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body.logto.status).toBe("up");
|
|
||||||
expect(elapsed).toBeLessThan(1500);
|
|
||||||
}, 15000);
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
177
agent/README.md
177
agent/README.md
|
|
@ -1,177 +0,0 @@
|
||||||
# Workstation agent
|
|
||||||
|
|
||||||
Collects one snapshot of the machine it runs on and pushes it to
|
|
||||||
`POST /hosts/<id>` on the health API. Cron runs it every 5 minutes; the
|
|
||||||
dashboard reads the result through `GET /hosts`.
|
|
||||||
|
|
||||||
Zero dependencies — `node:http` / `node:https` and the `metrics.js` already
|
|
||||||
shared with the server. Node 22+.
|
|
||||||
|
|
||||||
| File | Role |
|
|
||||||
|------|------|
|
|
||||||
| `push-metrics.js` | Collect, build the payload, POST once, exit |
|
|
||||||
| `run-push.sh` | Cron wrapper: load the env file, check the install, exec node |
|
|
||||||
|
|
||||||
This directory is **not** part of the server image: the `Dockerfile` copies
|
|
||||||
`package.json index.js metrics.js` one by one, and a test pins that it stays
|
|
||||||
that way. The agent ships by copying files onto a workstation, never by deploy.
|
|
||||||
|
|
||||||
## What it deliberately does not do
|
|
||||||
|
|
||||||
**No queue, no replay.** One run is one attempt. A push that fails is dropped,
|
|
||||||
logged, and the process exits non-zero — the next beat is five minutes away and
|
|
||||||
a heartbeat from five minutes ago describes a machine that no longer exists.
|
|
||||||
Nothing is spooled to disk, so nothing can ever be replayed to make the
|
|
||||||
dashboard show a past that is no longer true.
|
|
||||||
|
|
||||||
**No secret in the logs.** `run-push.sh` is meant to be piped into `logger`, so
|
|
||||||
everything the agent prints ends up in syslog and journald permanently. The
|
|
||||||
failure path therefore reports **only** `err.code` and the HTTP status — never
|
|
||||||
an error object, never the request options, never a response body, and never a
|
|
||||||
header. That is also why `run-push.sh` must never gain a `set -x`: the shell
|
|
||||||
would echo the token as it sources the env file.
|
|
||||||
|
|
||||||
The cost is real and accepted: a `400` tells you the server rejected the
|
|
||||||
payload, not why. The reason is in the server's logs, and `--dry-run` (below)
|
|
||||||
shows you the exact payload that was sent.
|
|
||||||
|
|
||||||
## Install on a new workstation
|
|
||||||
|
|
||||||
### 1. Freeze a copy
|
|
||||||
|
|
||||||
The cron must point at a copy, **never at the git working tree**. The server is
|
|
||||||
deployed by a manual trigger, so the checked-out repo drifts freely: a branch
|
|
||||||
checkout in `~/claude-code/vps-health-api` would silently change — or break —
|
|
||||||
the heartbeat of the workstation, and nothing would say so.
|
|
||||||
|
|
||||||
`push-metrics.js` loads `metrics.js` from one directory up, so the copy keeps
|
|
||||||
the repo layout:
|
|
||||||
|
|
||||||
```
|
|
||||||
mkdir -p ~/.local/share/maximus-host-agent/agent
|
|
||||||
cp metrics.js ~/.local/share/maximus-host-agent/
|
|
||||||
cp agent/push-metrics.js agent/run-push.sh ~/.local/share/maximus-host-agent/agent/
|
|
||||||
chmod +x ~/.local/share/maximus-host-agent/agent/run-push.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-run those three `cp` after any change to the agent — that copy step *is* the
|
|
||||||
deployment.
|
|
||||||
|
|
||||||
### 2. Write the env file
|
|
||||||
|
|
||||||
`~/.config/maximus-host-agent.env`, mode `600` (the agent warns on any other
|
|
||||||
mode). Use `printf`, not a heredoc: a trailing newline or space inside the token
|
|
||||||
value is the most common install failure.
|
|
||||||
|
|
||||||
```
|
|
||||||
HOSTS_API_URL=https://health.lacompagniemaximus.com
|
|
||||||
HOSTS_INGEST_TOKEN=<the ingest token>
|
|
||||||
HOST_ID=thinkpad
|
|
||||||
# NODE_BIN=/usr/bin/node # only if cron cannot find node (see below)
|
|
||||||
# HOST_AGENT_TIMEOUT_MS=10000 # push timeout, wall-clock; default 10000
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
chmod 600 ~/.config/maximus-host-agent.env
|
|
||||||
```
|
|
||||||
|
|
||||||
`HOSTS_INGEST_TOKEN` is the write-only token: it can push snapshots for an
|
|
||||||
allowlisted id and nothing else. It is deliberately *not* `HEALTH_TOKEN`, so
|
|
||||||
that the two can be rotated independently and so a workstation that only pushes
|
|
||||||
never needs the read token.
|
|
||||||
|
|
||||||
Do not read more into that separation than it gives you. On the ThinkPad it
|
|
||||||
buys no containment at all: that machine already stores `HEALTH_TOKEN` in
|
|
||||||
cleartext for the `defenseur-auto` cron, so losing the laptop compromises both
|
|
||||||
tokens. **Rotate them together** — see `secret-rotation-ops.md`.
|
|
||||||
|
|
||||||
`HOST_ID` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in the server's
|
|
||||||
`HOSTS_ALLOWED_IDS`, otherwise the push comes back `403`.
|
|
||||||
|
|
||||||
### 3. Dry run
|
|
||||||
|
|
||||||
Collect and print the payload without touching the network or needing any
|
|
||||||
config:
|
|
||||||
|
|
||||||
```
|
|
||||||
node ~/.local/share/maximus-host-agent/agent/push-metrics.js --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
Then one real push, by hand, before installing the cron:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.local/share/maximus-host-agent/agent/run-push.sh
|
|
||||||
# host-agent: ok id=thinkpad status=204 hostname=thinkpad-x1
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm the server side (from a machine that holds `HEALTH_TOKEN`):
|
|
||||||
|
|
||||||
```
|
|
||||||
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
|
|
||||||
https://health.lacompagniemaximus.com/hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
The entry for your id should show `online: true` and a small `ageSeconds`.
|
|
||||||
|
|
||||||
### 4. Install the cron
|
|
||||||
|
|
||||||
```
|
|
||||||
crontab -e
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
*/5 * * * * $HOME/.local/share/maximus-host-agent/agent/run-push.sh 2>&1 | logger -t host-agent
|
|
||||||
```
|
|
||||||
|
|
||||||
Read what it did:
|
|
||||||
|
|
||||||
```
|
|
||||||
journalctl -t host-agent --since -1h
|
|
||||||
```
|
|
||||||
|
|
||||||
If the line works by hand but produces nothing under cron, it is almost always
|
|
||||||
`node`: cron's `PATH` is `/usr/bin:/bin`, and a node installed by nvm or under
|
|
||||||
`/usr/local` is not on it. Set `NODE_BIN` to an absolute path in the env file —
|
|
||||||
the wrapper says so explicitly rather than failing silently.
|
|
||||||
|
|
||||||
## One id per workstation
|
|
||||||
|
|
||||||
The server keeps **one file per host id** and the last write wins. Two
|
|
||||||
workstations configured with the same `HOST_ID` therefore overwrite each other
|
|
||||||
every five minutes, and nothing anywhere reports an error: the dashboard shows
|
|
||||||
one host that looks perfectly healthy while its numbers come from whichever
|
|
||||||
machine pushed last.
|
|
||||||
|
|
||||||
The signal to watch for is the `hostname` field in `GET /hosts` **changing
|
|
||||||
between polls** while the id stays the same — plus CPU and memory that jump
|
|
||||||
around without pattern. If you see that, two machines are sharing an id.
|
|
||||||
|
|
||||||
Give every workstation its own id and add it to `HOSTS_ALLOWED_IDS` on the
|
|
||||||
server before installing the agent on it.
|
|
||||||
|
|
||||||
## Exit codes
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| 0 | Snapshot accepted (server answered 2xx, normally `204`) |
|
|
||||||
| 1 | Install problem: env file missing, a variable unset, malformed `HOST_ID`, node not found, `metrics.js` not found next to the agent |
|
|
||||||
| 2 | The push did not land: network error (`code=…`) or a non-2xx answer (`HTTP …`) |
|
|
||||||
|
|
||||||
A `1` needs a human on the workstation. A `2` usually fixes itself on the next
|
|
||||||
beat; a `2` that repeats for an hour means the API or the link is down.
|
|
||||||
|
|
||||||
## Payload
|
|
||||||
|
|
||||||
Exactly the `GET /health` body minus `logto` and `timestamp`:
|
|
||||||
|
|
||||||
```
|
|
||||||
{ hostname, uptime,
|
|
||||||
cpu: { model, cores, loadAvg, usagePercent },
|
|
||||||
memory: { totalGB, usedGB, freeGB, usagePercent },
|
|
||||||
disk: { totalGB, usedGB, freeGB, usagePercent } }
|
|
||||||
```
|
|
||||||
|
|
||||||
Sharing `metrics.js` with the server keeps the two in step, but it is not the
|
|
||||||
guarantee — the frozen copy can lag behind a deployed server. The real contract
|
|
||||||
is the server's own validation, and `__tests__/agent.test.js` checks the agent's
|
|
||||||
payload against it (including a real snapshot through the real handler).
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
// Local workstation agent: collect one snapshot and POST it to /hosts/<id>.
|
|
||||||
// Runs from cron every 5 minutes on the ThinkPad. Zero runtime dependencies —
|
|
||||||
// node:http / node:https only, same posture as the server it talks to.
|
|
||||||
//
|
|
||||||
// Two properties are load-bearing and easy to break by accident.
|
|
||||||
//
|
|
||||||
// * NO QUEUE, NO REPLAY. One run = one attempt. A missed beat is replaced by
|
|
||||||
// the next one five minutes later; a stale heartbeat has no value, and a
|
|
||||||
// spool file would only ever let the dashboard display a past that is no
|
|
||||||
// longer true. Every failure exits non-zero and stops there.
|
|
||||||
// * THE FAILURE PATH NEVER PRINTS A REQUEST OBJECT. run-push.sh pipes this
|
|
||||||
// process' output into `logger -t host-agent`, so anything written here
|
|
||||||
// lands in /var/log/syslog and journald permanently. An error object or an
|
|
||||||
// options bag carrying the Authorization header would persist the ingest
|
|
||||||
// token on disk, in cleartext, forever. Failures are therefore rebuilt from
|
|
||||||
// a fixed template plus err.code or the HTTP status — nothing else.
|
|
||||||
// __tests__/agent.test.js asserts no fragment of the token survives into
|
|
||||||
// the process output.
|
|
||||||
|
|
||||||
const os = require("node:os");
|
|
||||||
const http = require("node:http");
|
|
||||||
const https = require("node:https");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 10000;
|
|
||||||
|
|
||||||
// Exit codes are the only signal cron has. 1 means "the install is wrong"
|
|
||||||
// (fix the env file), 2 means "the push did not land" (network or server).
|
|
||||||
const EXIT_CONFIG = 1;
|
|
||||||
const EXIT_PUSH = 2;
|
|
||||||
|
|
||||||
// Mirrors HOST_ID_PATTERN in index.js. Checked client-side so a typo in the env
|
|
||||||
// file fails with a readable message instead of an opaque 404 from the server.
|
|
||||||
const HOST_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
||||||
|
|
||||||
// --- Payload ----------------------------------------------------------------
|
|
||||||
|
|
||||||
function pickUsage(value) {
|
|
||||||
return {
|
|
||||||
totalGB: value.totalGB,
|
|
||||||
usedGB: value.usedGB,
|
|
||||||
freeGB: value.freeGB,
|
|
||||||
usagePercent: value.usagePercent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the POST /hosts/<id> body: the GET /health payload minus `logto` and
|
|
||||||
// `timestamp`. Fields are copied one by one rather than spread, so a future
|
|
||||||
// addition to collectMetrics() cannot silently widen what leaves the
|
|
||||||
// workstation. `uptime` is floored exactly like getHealth() does.
|
|
||||||
function buildSnapshot(metrics) {
|
|
||||||
return {
|
|
||||||
hostname: os.hostname(),
|
|
||||||
uptime: Math.floor(os.uptime()),
|
|
||||||
cpu: {
|
|
||||||
model: metrics.cpu.model,
|
|
||||||
cores: metrics.cpu.cores,
|
|
||||||
loadAvg: metrics.cpu.loadAvg,
|
|
||||||
usagePercent: metrics.cpu.usagePercent,
|
|
||||||
},
|
|
||||||
memory: pickUsage(metrics.memory),
|
|
||||||
disk: pickUsage(metrics.disk),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Failure messages -------------------------------------------------------
|
|
||||||
//
|
|
||||||
// The ONLY three places a failure string is built. Keep it that way: every
|
|
||||||
// caller below reports through one of these, none of them accepts an error
|
|
||||||
// object, and none of them is given the token or the request options.
|
|
||||||
|
|
||||||
function configFailure(reason) {
|
|
||||||
return new Error(`configuration error: ${reason}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function transportFailure(code) {
|
|
||||||
return new Error(`push failed: request error (code=${code || "UNKNOWN"})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function httpFailure(statusCode) {
|
|
||||||
return new Error(`push failed: HTTP ${statusCode}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Config -----------------------------------------------------------------
|
|
||||||
|
|
||||||
function readConfig(env) {
|
|
||||||
const apiUrl = (env.HOSTS_API_URL || "").trim();
|
|
||||||
const token = env.HOSTS_INGEST_TOKEN || "";
|
|
||||||
const hostId = (env.HOST_ID || "").trim();
|
|
||||||
|
|
||||||
if (!apiUrl) return { error: configFailure("HOSTS_API_URL is not set") };
|
|
||||||
if (!token) return { error: configFailure("HOSTS_INGEST_TOKEN is not set") };
|
|
||||||
if (!hostId) return { error: configFailure("HOST_ID is not set") };
|
|
||||||
if (!HOST_ID_RE.test(hostId)) {
|
|
||||||
return {
|
|
||||||
error: configFailure("HOST_ID must match ^[a-z0-9][a-z0-9-]{0,31}$"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shape-check the URL here too, so a broken env file exits 1 ("fix the
|
|
||||||
// install") rather than 2 ("the network is having a bad day"). postSnapshot
|
|
||||||
// repeats the check because it is also called directly by the tests.
|
|
||||||
let target;
|
|
||||||
try {
|
|
||||||
target = buildTargetUrl(apiUrl, hostId);
|
|
||||||
} catch {
|
|
||||||
return { error: configFailure("HOSTS_API_URL is not a valid URL") };
|
|
||||||
}
|
|
||||||
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
||||||
return { error: configFailure("HOSTS_API_URL must be http:// or https://") };
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeoutRaw = parseInt(env.HOST_AGENT_TIMEOUT_MS || "", 10);
|
|
||||||
const timeoutMs =
|
|
||||||
Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_TIMEOUT_MS;
|
|
||||||
|
|
||||||
return { config: { apiUrl, token, hostId, timeoutMs } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Delivery ---------------------------------------------------------------
|
|
||||||
|
|
||||||
function buildTargetUrl(apiUrl, hostId) {
|
|
||||||
const base = String(apiUrl).replace(/\/+$/, "");
|
|
||||||
return new URL(`${base}/hosts/${hostId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// One attempt, hard-bounded in wall-clock time. Resolves to { statusCode } on
|
|
||||||
// 2xx, rejects with a template message otherwise. No retry lives here or above.
|
|
||||||
function postSnapshot({ apiUrl, token, hostId, snapshot, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let target;
|
|
||||||
try {
|
|
||||||
target = buildTargetUrl(apiUrl, hostId);
|
|
||||||
} catch {
|
|
||||||
reject(configFailure("HOSTS_API_URL is not a valid URL"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const client =
|
|
||||||
target.protocol === "https:" ? https : target.protocol === "http:" ? http : null;
|
|
||||||
if (!client) {
|
|
||||||
reject(configFailure("HOSTS_API_URL must be http:// or https://"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = Buffer.from(JSON.stringify(snapshot), "utf-8");
|
|
||||||
let settled = false;
|
|
||||||
let req = null;
|
|
||||||
|
|
||||||
const finish = (fn, arg) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
fn(arg);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Wall-clock ceiling over the whole exchange: DNS, connect, TLS, response.
|
|
||||||
// The `timeout` request option only covers socket inactivity, which can add
|
|
||||||
// up past the budget on a flaky link. destroy() surfaces as an 'error' with
|
|
||||||
// the code below, so the timeout reports as ETIMEDOUT and nothing else.
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (settled || !req) return;
|
|
||||||
req.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
req = client.request(
|
|
||||||
target,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Content-Length": body.length,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
(res) => {
|
|
||||||
const { statusCode } = res;
|
|
||||||
// Drain without reading: the response body is never inspected and
|
|
||||||
// never logged. A 400 says the payload was rejected; the reason is in
|
|
||||||
// the server's logs, where it can be read without a token nearby.
|
|
||||||
res.resume();
|
|
||||||
res.on("end", () => {
|
|
||||||
if (statusCode >= 200 && statusCode < 300) {
|
|
||||||
finish(resolve, { statusCode });
|
|
||||||
} else {
|
|
||||||
finish(reject, httpFailure(statusCode));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
res.on("error", (err) => finish(reject, transportFailure(err && err.code)));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
// request() throws synchronously on an unusable header — in practice a
|
|
||||||
// token with a trailing newline, the classic env-file mistake. Node's
|
|
||||||
// ERR_INVALID_CHAR names the header but never quotes its value; it is
|
|
||||||
// still converted here rather than propagated, so no error object from
|
|
||||||
// the HTTP stack can ever reach a console call.
|
|
||||||
if (err && err.code === "ERR_INVALID_CHAR") {
|
|
||||||
finish(
|
|
||||||
reject,
|
|
||||||
configFailure(
|
|
||||||
"HOSTS_INGEST_TOKEN contains an invalid character (a trailing newline in the env file is the usual cause)",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
finish(reject, transportFailure(err && err.code));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// err.code and NOTHING else. err.message can carry the resolved address,
|
|
||||||
// and the error object dragged into a console call would carry the whole
|
|
||||||
// request — headers included.
|
|
||||||
req.on("error", (err) => finish(reject, transportFailure(err && err.code)));
|
|
||||||
|
|
||||||
req.end(body);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Entry point ------------------------------------------------------------
|
|
||||||
|
|
||||||
// metrics.js is shared with the server and lives one directory up. The frozen
|
|
||||||
// install copy must keep that layout (see README) — when it does not, say so in
|
|
||||||
// one line rather than let cron mail a MODULE_NOT_FOUND stack.
|
|
||||||
function loadCollectMetrics() {
|
|
||||||
try {
|
|
||||||
return require(path.join(__dirname, "..", "metrics.js")).collectMetrics;
|
|
||||||
} catch (err) {
|
|
||||||
if (err && err.code === "MODULE_NOT_FOUND") {
|
|
||||||
throw configFailure(
|
|
||||||
"metrics.js not found one level above the agent — the install copy must keep the repo layout (see agent/README.md)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(argv = process.argv.slice(2), env = process.env) {
|
|
||||||
// Dry run: collect and print the payload, touch no network, need no config.
|
|
||||||
// This is what commissioning runs first, before the env file even exists.
|
|
||||||
const dryRun = argv.includes("--dry-run");
|
|
||||||
|
|
||||||
// Config first: the CPU sample below costs 500ms, and there is no point
|
|
||||||
// spending it to then discover HOST_ID is a typo.
|
|
||||||
let config = null;
|
|
||||||
if (!dryRun) {
|
|
||||||
const result = readConfig(env);
|
|
||||||
if (result.error) {
|
|
||||||
console.error(`host-agent: ${result.error.message}`);
|
|
||||||
return EXIT_CONFIG;
|
|
||||||
}
|
|
||||||
config = result.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot;
|
|
||||||
try {
|
|
||||||
const collectMetrics = loadCollectMetrics();
|
|
||||||
snapshot = buildSnapshot(await collectMetrics());
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`host-agent: ${err.message}`);
|
|
||||||
return EXIT_CONFIG;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(JSON.stringify(snapshot, null, 2));
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { statusCode } = await postSnapshot({ ...config, snapshot });
|
|
||||||
console.log(
|
|
||||||
`host-agent: ok id=${config.hostId} status=${statusCode} hostname=${snapshot.hostname}`,
|
|
||||||
);
|
|
||||||
return 0;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`host-agent: ${err.message}`);
|
|
||||||
return EXIT_PUSH;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
main().then(
|
|
||||||
(code) => {
|
|
||||||
process.exitCode = code;
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
// Nothing should reach here — every path above is handled. Print the
|
|
||||||
// message only, never the error object: a stack rendered by console.error
|
|
||||||
// is fine, but an object argument is what leaks headers.
|
|
||||||
console.error(`host-agent: unexpected failure: ${err && err.message}`);
|
|
||||||
process.exitCode = EXIT_PUSH;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildSnapshot,
|
|
||||||
buildTargetUrl,
|
|
||||||
postSnapshot,
|
|
||||||
readConfig,
|
|
||||||
main,
|
|
||||||
DEFAULT_TIMEOUT_MS,
|
|
||||||
EXIT_CONFIG,
|
|
||||||
EXIT_PUSH,
|
|
||||||
HOST_ID_RE,
|
|
||||||
};
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
#
|
|
||||||
# Cron wrapper for push-metrics.js: load the secrets, check the install, hand
|
|
||||||
# over to node. Every five minutes, forever, unattended.
|
|
||||||
#
|
|
||||||
# NEVER add `set -x`. This script is meant to be piped into `logger`, and the
|
|
||||||
# shell would echo the `. "$ENV_FILE"` expansion — the ingest token — straight
|
|
||||||
# into /var/log/syslog and journald, where it would stay. Same reason nothing
|
|
||||||
# below ever echoes a variable that holds a secret: the checks print the NAME of
|
|
||||||
# what is missing, never its value.
|
|
||||||
#
|
|
||||||
# Suggested crontab line (see README.md):
|
|
||||||
# */5 * * * * $HOME/.local/share/maximus-host-agent/agent/run-push.sh 2>&1 | logger -t host-agent
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
||||||
AGENT="$SCRIPT_DIR/push-metrics.js"
|
|
||||||
|
|
||||||
# Overridable for testing only; cron uses the default.
|
|
||||||
ENV_FILE="${HOST_AGENT_ENV_FILE:-$HOME/.config/maximus-host-agent.env}"
|
|
||||||
|
|
||||||
if [ ! -f "$ENV_FILE" ]; then
|
|
||||||
echo "host-agent: env file not found: $ENV_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# A warning, not a refusal: a wrong mode bit is worth shouting about, but it is
|
|
||||||
# not worth silencing the heartbeat of the whole workstation over.
|
|
||||||
FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "")
|
|
||||||
if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then
|
|
||||||
echo "host-agent: warning: $ENV_FILE is mode $FILE_MODE, expected 600" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# shellcheck source=/dev/null
|
|
||||||
. "$ENV_FILE"
|
|
||||||
|
|
||||||
if [ -z "${HOSTS_API_URL:-}" ]; then
|
|
||||||
echo "host-agent: HOSTS_API_URL is not set in $ENV_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "${HOSTS_INGEST_TOKEN:-}" ]; then
|
|
||||||
echo "host-agent: HOSTS_INGEST_TOKEN is not set in $ENV_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -z "${HOST_ID:-}" ]; then
|
|
||||||
echo "host-agent: HOST_ID is not set in $ENV_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$AGENT" ]; then
|
|
||||||
echo "host-agent: agent not found: $AGENT" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# cron's PATH is famously short (/usr/bin:/bin), and a node installed through
|
|
||||||
# nvm or /usr/local lives outside it. Set NODE_BIN in the env file when
|
|
||||||
# `command -v node` comes up empty under cron but works in a login shell.
|
|
||||||
NODE_BIN="${NODE_BIN:-node}"
|
|
||||||
if ! command -v "$NODE_BIN" >/dev/null 2>&1; then
|
|
||||||
echo "host-agent: node not found (NODE_BIN=$NODE_BIN); set NODE_BIN in $ENV_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# HOST_AGENT_TIMEOUT_MS is optional and read by push-metrics.js. It must be
|
|
||||||
# exported too, otherwise setting it in the env file silently does nothing and
|
|
||||||
# the agent always runs on its 10s default.
|
|
||||||
export HOSTS_API_URL HOSTS_INGEST_TOKEN HOST_ID HOST_AGENT_TIMEOUT_MS
|
|
||||||
|
|
||||||
exec "$NODE_BIN" "$AGENT" "$@"
|
|
||||||
746
index.js
746
index.js
|
|
@ -1,100 +1,48 @@
|
||||||
const http = require("node:http");
|
const http = require("node:http");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const {
|
const { execSync } = require("node:child_process");
|
||||||
readFileSync,
|
const { readFileSync } = require("node:fs");
|
||||||
readdirSync,
|
const { setTimeout: delay } = require("node:timers/promises");
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
writeFileSync,
|
|
||||||
renameSync,
|
|
||||||
unlinkSync,
|
|
||||||
} = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
const { createHash, timingSafeEqual } = require("node:crypto");
|
|
||||||
const { collectMetrics } = require("./metrics.js");
|
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || "3001", 10);
|
const PORT = parseInt(process.env.PORT || "3001", 10);
|
||||||
const TOKEN = process.env.HEALTH_TOKEN;
|
const TOKEN = process.env.HEALTH_TOKEN;
|
||||||
const REPORTS_DIR = process.env.REPORTS_DIR || "/data/defenseurs/reports";
|
|
||||||
const AGENTS_MAP_PATH =
|
|
||||||
process.env.DEFENSEURS_AGENTS_MAP_PATH || "/data/defenseurs/agents-map.json";
|
|
||||||
const LOGTO_HEALTH_URL =
|
const LOGTO_HEALTH_URL =
|
||||||
process.env.LOGTO_HEALTH_URL ||
|
process.env.LOGTO_HEALTH_URL ||
|
||||||
"https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration";
|
"https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration";
|
||||||
const LOGTO_TIMEOUT_MS = 3000;
|
const LOGTO_TIMEOUT_MS = 3000;
|
||||||
const SCAN_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
||||||
|
|
||||||
const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
|
|
||||||
const VALID_SEVERITIES = Object.keys(SEVERITY_RANK);
|
|
||||||
const VALID_CATEGORIES = ["deps", "secrets", "code", "acces", "infra"];
|
|
||||||
|
|
||||||
// --- Workstation snapshots (POST /hosts/<id>, GET /hosts) -------------------
|
|
||||||
|
|
||||||
const HOSTS_DIR = process.env.HOSTS_DIR || "/data/hosts";
|
|
||||||
const HOSTS_INGEST_TOKEN = process.env.HOSTS_INGEST_TOKEN;
|
|
||||||
const MAX_BODY_BYTES = 4096;
|
|
||||||
const MAX_STRING_LENGTH = 128;
|
|
||||||
|
|
||||||
// Single source of truth for what a host id may look like. The allowlist check
|
|
||||||
// and the route matcher are both built from it, so they can never drift apart.
|
|
||||||
//
|
|
||||||
// This narrow pattern IS the path-traversal control. Verified in Node: URL()
|
|
||||||
// normalises `/hosts/../../etc/passwd` to `/etc/passwd`, while `..%2f..%2f` and
|
|
||||||
// `THINKPAD` simply fail the match — every hostile id therefore lands on 404,
|
|
||||||
// before any handler runs. 403 is reserved for well-formed ids that are not in
|
|
||||||
// the allowlist. Never widen this to `^/hosts/(.+)$` to make a test pass.
|
|
||||||
const HOST_ID_PATTERN = "[a-z0-9][a-z0-9-]{0,31}";
|
|
||||||
const HOST_ID_RE = new RegExp(`^${HOST_ID_PATTERN}$`);
|
|
||||||
const HOST_ROUTE_RE = new RegExp(`^/hosts/(${HOST_ID_PATTERN})$`);
|
|
||||||
|
|
||||||
const staleSecondsRaw = parseInt(process.env.HOSTS_STALE_SECONDS || "", 10);
|
|
||||||
const HOSTS_STALE_SECONDS =
|
|
||||||
Number.isFinite(staleSecondsRaw) && staleSecondsRaw > 0 ? staleSecondsRaw : 900;
|
|
||||||
|
|
||||||
// Parse HOSTS_ALLOWED_IDS once at startup and drop anything that is not a valid
|
|
||||||
// host id. Entries feed path.join(HOSTS_DIR, `${id}.json`), so a stray space or
|
|
||||||
// an entry like `../defenseurs/status` must never survive into a file path.
|
|
||||||
// Rejections are logged rather than silently swallowed: a typo in the env var
|
|
||||||
// would otherwise look exactly like a workstation that never checked in.
|
|
||||||
function parseAllowedIds(raw) {
|
|
||||||
const allowed = new Set();
|
|
||||||
for (const entry of String(raw).split(",")) {
|
|
||||||
const id = entry.trim();
|
|
||||||
if (!id) continue;
|
|
||||||
if (!HOST_ID_RE.test(id)) {
|
|
||||||
console.warn(
|
|
||||||
`WARNING: HOSTS_ALLOWED_IDS entry rejected (not a valid host id): ${safeLogValue(id)}`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
allowed.add(id);
|
|
||||||
}
|
|
||||||
return allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HOSTS_ALLOWED_IDS = parseAllowedIds(process.env.HOSTS_ALLOWED_IDS || "thinkpad");
|
|
||||||
|
|
||||||
// Severity filter. Asymmetric rule (issue #3):
|
|
||||||
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
|
|
||||||
// - threshold "INFO" -> INFO only (explicit opt-in)
|
|
||||||
// - any other -> everything at or above threshold, EXCEPT INFO
|
|
||||||
// INFO is therefore reachable only via explicit ?severity=INFO.
|
|
||||||
function allowedSeverities(threshold) {
|
|
||||||
if (!threshold) return ["CRITICAL", "HIGH", "MEDIUM"];
|
|
||||||
if (threshold === "INFO") return ["INFO"];
|
|
||||||
const min = SEVERITY_RANK[threshold];
|
|
||||||
return VALID_SEVERITIES.filter(
|
|
||||||
(s) => s !== "INFO" && SEVERITY_RANK[s] >= min,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!TOKEN) {
|
if (!TOKEN) {
|
||||||
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
|
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
|
||||||
}
|
}
|
||||||
|
|
||||||
// No startup warning for a missing HOSTS_INGEST_TOKEN: the ingestion path
|
function readProcStat() {
|
||||||
// fail-closes to 503 and logs the reason on the request that hits it, which is
|
try {
|
||||||
// the moment an operator actually needs to see it.
|
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
|
||||||
|
const parts = line.split(/\s+/).slice(1).map(Number);
|
||||||
|
const idle = parts[3] + parts[4];
|
||||||
|
const total = parts.reduce((a, b) => a + b, 0);
|
||||||
|
return { idle, total };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCpuPercent() {
|
||||||
|
const t1 = readProcStat();
|
||||||
|
if (!t1) return 0;
|
||||||
|
const { idle: idle1, total: total1 } = t1;
|
||||||
|
|
||||||
|
// Sample over 500ms without blocking the event loop, so other async work
|
||||||
|
// (e.g. the Logto healthcheck) can run concurrently.
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
|
const t2 = readProcStat();
|
||||||
|
if (!t2) return 0;
|
||||||
|
const dIdle = t2.idle - idle1;
|
||||||
|
const dTotal = t2.total - total1;
|
||||||
|
if (dTotal === 0) return 0;
|
||||||
|
return Math.round((1 - dIdle / dTotal) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
async function getLogtoHealth() {
|
async function getLogtoHealth() {
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
|
|
@ -114,117 +62,30 @@ async function getLogtoHealth() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reproduce the isScanReport guard from defenseurs/src/report.ts. The
|
function getDisk() {
|
||||||
// defenseur-auto run report has shape { actions[], skipped[] } with no
|
|
||||||
// findings[] — it must be filtered out so it never reaches the auto pipeline
|
|
||||||
// (which expects scan-shaped reports only).
|
|
||||||
function isScanReport(value) {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
Array.isArray(value.findings) &&
|
|
||||||
typeof value.agent === "string" &&
|
|
||||||
typeof value.timestamp === "string"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read all `defenseur-<agent>_<date>*.json` files under `dir` matching the
|
|
||||||
// given UTC date. Returns parsed scan reports keyed by filename so the caller
|
|
||||||
// can dedupe across REPORTS_DIR + REPORTS_DIR/archive.
|
|
||||||
function collectScanReportsFromDir(dir, date) {
|
|
||||||
const collected = new Map();
|
|
||||||
if (!existsSync(dir)) return collected;
|
|
||||||
|
|
||||||
const files = readdirSync(dir).filter(
|
|
||||||
(f) => f.startsWith("defenseur-") && f.includes(`_${date}`) && f.endsWith(".json"),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
try {
|
||||||
const raw = readFileSync(path.join(dir, file), "utf-8");
|
// Alpine df doesn't support --output, use standard POSIX format
|
||||||
const parsed = JSON.parse(raw);
|
const out = execSync("df -k /", { encoding: "utf-8" });
|
||||||
if (!isScanReport(parsed)) continue;
|
const parts = out.trim().split("\n")[1].trim().split(/\s+/);
|
||||||
if (!parsed.timestamp.startsWith(date)) continue;
|
// df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted
|
||||||
collected.set(file, parsed);
|
const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1);
|
||||||
} catch (err) {
|
const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1);
|
||||||
console.error(`[reports/scans] failed to parse ${file}:`, err.message);
|
const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1);
|
||||||
|
const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0;
|
||||||
|
return { totalGB, usedGB, freeGB, usagePercent };
|
||||||
|
} catch {
|
||||||
|
return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return collected;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read all `defenseur-<agent>_<date>*.json` files under REPORTS_DIR for the
|
|
||||||
// given UTC date. The scan reports use an ISO timestamp with `:` and `.`
|
|
||||||
// rewritten as `-` in the filename (e.g. defenseur-booking_2026-05-06T05-30-11-249Z.json).
|
|
||||||
// We match `_<date>` then re-confirm via parsed.timestamp.startsWith(date).
|
|
||||||
//
|
|
||||||
// The Sergent rotates fresh reports out of REPORTS_DIR into REPORTS_DIR/archive
|
|
||||||
// at 07:30 UTC daily (cf. defenseurs/src/sergent.ts renameSync). For ~22h/day
|
|
||||||
// the only copy lives in archive/ — so we scan both and concatenate. Top-level
|
|
||||||
// files take precedence on filename collision (more recent by definition).
|
|
||||||
function readScanReportsForDate(date) {
|
|
||||||
const topLevel = collectScanReportsFromDir(REPORTS_DIR, date);
|
|
||||||
const archive = collectScanReportsFromDir(path.join(REPORTS_DIR, "archive"), date);
|
|
||||||
|
|
||||||
// Merge with top-level priority — only insert archive entries whose filename
|
|
||||||
// is not already present at the top level.
|
|
||||||
for (const [file, report] of archive) {
|
|
||||||
if (!topLevel.has(file)) topLevel.set(file, report);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stable sort by timestamp asc — same convention as readReports() in
|
|
||||||
// defenseurs/src/report.ts.
|
|
||||||
return [...topLevel.values()].sort(
|
|
||||||
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the latest scan report for a given agent across REPORTS_DIR and
|
|
||||||
// REPORTS_DIR/archive. Files are named `defenseur-<agent>_<iso>.json`.
|
|
||||||
// The trailing underscore in the prefix prevents collisions on agent names
|
|
||||||
// that share a prefix (e.g. "booking" vs "booking-staging").
|
|
||||||
function findLatestReportForAgent(agent) {
|
|
||||||
// `agent` is the full identifier as written by the Sergent into
|
|
||||||
// agents-map.json (e.g. "defenseur-booking") — matches both the JSON
|
|
||||||
// `agent` field and the filename prefix `<agent>_<iso>.json`.
|
|
||||||
const prefix = `${agent}_`;
|
|
||||||
const dirs = [REPORTS_DIR, path.join(REPORTS_DIR, "archive")];
|
|
||||||
let latest = null;
|
|
||||||
|
|
||||||
for (const dir of dirs) {
|
|
||||||
if (!existsSync(dir)) continue;
|
|
||||||
const files = readdirSync(dir).filter(
|
|
||||||
(f) => f.startsWith(prefix) && f.endsWith(".json"),
|
|
||||||
);
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const raw = readFileSync(path.join(dir, file), "utf-8");
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!isScanReport(parsed)) continue;
|
|
||||||
if (parsed.agent !== agent) continue;
|
|
||||||
const ts = new Date(parsed.timestamp).getTime();
|
|
||||||
if (Number.isNaN(ts)) continue;
|
|
||||||
if (!latest || ts > latest._ts) {
|
|
||||||
latest = parsed;
|
|
||||||
latest._ts = ts;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[defenseurs/findings] failed to parse ${file}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latest) delete latest._ts;
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getHealth() {
|
async function getHealth() {
|
||||||
// Keep the Promise.all: the 500ms CPU sample inside collectMetrics() and the
|
const cpus = os.cpus();
|
||||||
// up-to-3s Logto check must stay concurrent. Awaiting them one after the
|
const totalMem = os.totalmem();
|
||||||
// other would push the p99 of /health to ~3.5s.
|
const freeMem = os.freemem();
|
||||||
const [metrics, logto] = await Promise.all([
|
const usedMem = totalMem - freeMem;
|
||||||
collectMetrics(),
|
|
||||||
|
const [cpuUsagePercent, logto] = await Promise.all([
|
||||||
|
getCpuPercent(),
|
||||||
getLogtoHealth(),
|
getLogtoHealth(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -232,231 +93,47 @@ async function getHealth() {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
hostname: os.hostname(),
|
hostname: os.hostname(),
|
||||||
uptime: Math.floor(os.uptime()),
|
uptime: Math.floor(os.uptime()),
|
||||||
...metrics,
|
cpu: {
|
||||||
|
model: cpus[0]?.model?.trim() || "unknown",
|
||||||
|
cores: cpus.length,
|
||||||
|
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
|
||||||
|
usagePercent: cpuUsagePercent,
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
totalGB: +(totalMem / 1e9).toFixed(1),
|
||||||
|
usedGB: +(usedMem / 1e9).toFixed(1),
|
||||||
|
freeGB: +(freeMem / 1e9).toFixed(1),
|
||||||
|
usagePercent: Math.round((usedMem / totalMem) * 100),
|
||||||
|
},
|
||||||
|
disk: getDisk(),
|
||||||
logto,
|
logto,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Hosts helpers ----------------------------------------------------------
|
const server = http.createServer(async (req, res) => {
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
// Printable-ASCII-only, length-capped rendering for anything attacker-supplied
|
const validRoutes = ["/health", "/defenseurs"];
|
||||||
// that reaches a log line (X-Real-IP, host id, env entries). Keeps a crafted
|
if (req.method !== "GET" || !validRoutes.includes(req.url)) {
|
||||||
// value from forging extra log records.
|
res.writeHead(404);
|
||||||
function safeLogValue(value, max = 64) {
|
res.end(JSON.stringify({ error: "Not found" }));
|
||||||
return String(value).replace(/[^\x20-\x7e]/g, "?").slice(0, max);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log every 401/403. This is the first publicly writable surface on the API:
|
|
||||||
// without a trace, a token-guessing campaign would leave nothing behind.
|
|
||||||
function logRejection(req, status, reason, id) {
|
|
||||||
const ip = safeLogValue(req.headers["x-real-ip"] || req.socket?.remoteAddress || "unknown");
|
|
||||||
const method = safeLogValue(req.method, 8);
|
|
||||||
const url = safeLogValue(req.url, 120);
|
|
||||||
console.warn(
|
|
||||||
`[auth] ${status} ${method} ${url} ip=${ip} id=${id ? safeLogValue(id, 32) : "-"} reason=${reason}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constant-time bearer comparison for the ingestion path. Both sides are hashed
|
|
||||||
// first, so the buffers are always 32 bytes: timingSafeEqual never throws on
|
|
||||||
// mismatched lengths, and the length of the real token cannot leak.
|
|
||||||
//
|
|
||||||
// Deliberately scoped to the new write path. Realigning the existing
|
|
||||||
// HEALTH_TOKEN comparison touches every read route in production and is tracked
|
|
||||||
// separately (issue #17).
|
|
||||||
function tokenMatches(provided, expected) {
|
|
||||||
if (typeof provided !== "string" || typeof expected !== "string") return false;
|
|
||||||
const a = createHash("sha256").update(provided).digest();
|
|
||||||
const b = createHash("sha256").update(expected).digest();
|
|
||||||
return timingSafeEqual(a, b);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the request body with a hard 4 KiB ceiling. The counter runs on the
|
|
||||||
// bytes actually received, never on Content-Length: a client can lie in the
|
|
||||||
// header, and chunked transfer-encoding omits it altogether.
|
|
||||||
//
|
|
||||||
// Resolves to the raw string, or to null when the response has already been
|
|
||||||
// written (413) or the socket died — in which case the caller must not answer.
|
|
||||||
function readBody(req, res) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const chunks = [];
|
|
||||||
let size = 0;
|
|
||||||
let settled = false;
|
|
||||||
|
|
||||||
req.on("data", (chunk) => {
|
|
||||||
if (settled) return;
|
|
||||||
size += chunk.length;
|
|
||||||
if (size > MAX_BODY_BYTES) {
|
|
||||||
settled = true;
|
|
||||||
req.pause();
|
|
||||||
res.writeHead(413);
|
|
||||||
// Destroy only once the 413 has been flushed: req.destroy() tears down
|
|
||||||
// the socket, so cutting first would leave the client with a network
|
|
||||||
// error instead of a status code.
|
|
||||||
res.end(JSON.stringify({ error: "Payload too large" }), () => req.destroy());
|
|
||||||
resolve(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
chunks.push(chunk);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on("end", () => {
|
if (!TOKEN) {
|
||||||
if (settled) return;
|
res.writeHead(401);
|
||||||
settled = true;
|
res.end(JSON.stringify({ error: "HEALTH_TOKEN not configured" }));
|
||||||
resolve(Buffer.concat(chunks).toString("utf-8"));
|
return;
|
||||||
});
|
|
||||||
|
|
||||||
req.on("error", () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanString(value) {
|
const auth = req.headers["authorization"];
|
||||||
if (typeof value !== "string") return null;
|
if (auth !== `Bearer ${TOKEN}`) {
|
||||||
return value.slice(0, MAX_STRING_LENGTH);
|
res.writeHead(401);
|
||||||
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanNumber(value) {
|
if (req.url === "/defenseurs") {
|
||||||
return Number.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// { totalGB, usedGB, freeGB, usagePercent } — the shape shared by memory and disk.
|
|
||||||
function sanitizeUsage(value, label) {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
||||||
return { error: `${label} must be an object` };
|
|
||||||
}
|
|
||||||
const out = {};
|
|
||||||
for (const key of ["totalGB", "usedGB", "freeGB", "usagePercent"]) {
|
|
||||||
const num = cleanNumber(value[key]);
|
|
||||||
if (num === null) return { error: `${label}.${key} must be a finite number` };
|
|
||||||
out[key] = num;
|
|
||||||
}
|
|
||||||
return { value: out };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeCpu(value) {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
||||||
return { error: "cpu must be an object" };
|
|
||||||
}
|
|
||||||
const model = cleanString(value.model);
|
|
||||||
if (model === null) return { error: "cpu.model must be a string" };
|
|
||||||
const cores = cleanNumber(value.cores);
|
|
||||||
if (cores === null) return { error: "cpu.cores must be a finite number" };
|
|
||||||
const usagePercent = cleanNumber(value.usagePercent);
|
|
||||||
if (usagePercent === null) return { error: "cpu.usagePercent must be a finite number" };
|
|
||||||
if (!Array.isArray(value.loadAvg)) return { error: "cpu.loadAvg must be an array" };
|
|
||||||
const loadAvg = [];
|
|
||||||
for (const entry of value.loadAvg.slice(0, 3)) {
|
|
||||||
const num = cleanNumber(entry);
|
|
||||||
if (num === null) return { error: "cpu.loadAvg must contain finite numbers" };
|
|
||||||
loadAvg.push(num);
|
|
||||||
}
|
|
||||||
return { value: { model, cores, loadAvg, usagePercent } };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild the persisted object field by field from a whitelist — never store
|
|
||||||
// the payload verbatim. The file is read back by GET /hosts and ends up in the
|
|
||||||
// admin dashboard's React tree, so an unknown key (`__proto__` among them) or a
|
|
||||||
// 4 KiB hostname must never make it that far.
|
|
||||||
function sanitizeSnapshot(payload) {
|
|
||||||
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
||||||
return { error: "body must be a JSON object" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = cleanString(payload.hostname);
|
|
||||||
if (hostname === null) return { error: "hostname must be a string" };
|
|
||||||
|
|
||||||
const uptime = cleanNumber(payload.uptime);
|
|
||||||
if (uptime === null) return { error: "uptime must be a finite number" };
|
|
||||||
|
|
||||||
const cpu = sanitizeCpu(payload.cpu);
|
|
||||||
if (cpu.error) return cpu;
|
|
||||||
const memory = sanitizeUsage(payload.memory, "memory");
|
|
||||||
if (memory.error) return memory;
|
|
||||||
const disk = sanitizeUsage(payload.disk, "disk");
|
|
||||||
if (disk.error) return disk;
|
|
||||||
|
|
||||||
return {
|
|
||||||
snapshot: {
|
|
||||||
hostname,
|
|
||||||
uptime,
|
|
||||||
cpu: cpu.value,
|
|
||||||
memory: memory.value,
|
|
||||||
disk: disk.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atomic write: temp file in the SAME directory, then renameSync. rename is
|
|
||||||
// atomic within a filesystem, so a concurrent GET /hosts either sees the old
|
|
||||||
// snapshot or the new one — never a half-written JSON.
|
|
||||||
function writeHostSnapshot(id, record) {
|
|
||||||
mkdirSync(HOSTS_DIR, { recursive: true });
|
|
||||||
const finalPath = path.join(HOSTS_DIR, `${id}.json`);
|
|
||||||
const tmpPath = path.join(HOSTS_DIR, `.${id}.${process.pid}.${Date.now()}.tmp`);
|
|
||||||
try {
|
|
||||||
writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
|
|
||||||
renameSync(tmpPath, finalPath);
|
|
||||||
} catch (err) {
|
|
||||||
try {
|
|
||||||
unlinkSync(tmpPath);
|
|
||||||
} catch {
|
|
||||||
// Best effort — the temp file may never have been created.
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read one persisted snapshot. Anything missing, unparsable, off-shape or
|
|
||||||
// without a usable server timestamp degrades to null: GET /hosts then reports
|
|
||||||
// that entry as neverSeen instead of failing the whole response.
|
|
||||||
//
|
|
||||||
// The file was written by this service, but it lives on a writable bind-mount:
|
|
||||||
// it is re-run through the same whitelist on the way out rather than trusted.
|
|
||||||
function readHostSnapshot(id) {
|
|
||||||
const file = path.join(HOSTS_DIR, `${id}.json`);
|
|
||||||
let parsed;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code !== "ENOENT") {
|
|
||||||
console.error(`[hosts] failed to read snapshot for ${id}:`, err.message);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = sanitizeSnapshot(parsed);
|
|
||||||
if (result.error) {
|
|
||||||
console.error(`[hosts] snapshot for ${id} rejected on read: ${result.error}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null;
|
|
||||||
if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) {
|
|
||||||
console.error(`[hosts] snapshot for ${id} has no usable receivedAt`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...result.snapshot, receivedAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Route handlers ---------------------------------------------------------
|
|
||||||
|
|
||||||
async function handleHealth(req, res) {
|
|
||||||
try {
|
|
||||||
const data = await getHealth();
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
} catch (err) {
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDefenseurs(req, res) {
|
|
||||||
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
|
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
|
||||||
try {
|
try {
|
||||||
const status = readFileSync(statusPath, "utf-8");
|
const status = readFileSync(statusPath, "utf-8");
|
||||||
|
|
@ -466,280 +143,19 @@ function handleDefenseurs(req, res) {
|
||||||
res.writeHead(200);
|
res.writeHead(200);
|
||||||
res.end(JSON.stringify({ status: "no_data" }));
|
res.end(JSON.stringify({ status: "no_data" }));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleFindings(req, res, ctx) {
|
|
||||||
const project = ctx.parsedUrl.searchParams.get("project");
|
|
||||||
const category = ctx.parsedUrl.searchParams.get("category");
|
|
||||||
const severity = ctx.parsedUrl.searchParams.get("severity");
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
res.writeHead(400);
|
|
||||||
res.end(JSON.stringify({ error: "Bad request: project=<name> required" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (category && !VALID_CATEGORIES.includes(category)) {
|
|
||||||
res.writeHead(400);
|
|
||||||
res.end(JSON.stringify({ error: `Bad request: category must be one of ${VALID_CATEGORIES.join(",")}` }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (severity && !VALID_SEVERITIES.includes(severity)) {
|
|
||||||
res.writeHead(400);
|
|
||||||
res.end(JSON.stringify({ error: `Bad request: severity must be one of ${VALID_SEVERITIES.join(",")}` }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let agentsMap;
|
|
||||||
try {
|
|
||||||
agentsMap = JSON.parse(readFileSync(AGENTS_MAP_PATH, "utf-8"));
|
|
||||||
} catch (err) {
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = agentsMap[project];
|
|
||||||
if (!agent) {
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end(JSON.stringify({ error: `Unknown project: ${project}` }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const report = findLatestReportForAgent(agent);
|
const data = await getHealth();
|
||||||
if (!report) {
|
|
||||||
res.writeHead(200);
|
res.writeHead(200);
|
||||||
res.end(JSON.stringify({ findings: [], status: "no_data" }));
|
res.end(JSON.stringify(data));
|
||||||
return;
|
|
||||||
}
|
|
||||||
const allowed = new Set(allowedSeverities(severity));
|
|
||||||
const findings = report.findings.filter(
|
|
||||||
(f) => allowed.has(f.severity) && (!category || f.category === category),
|
|
||||||
);
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
agent,
|
|
||||||
project,
|
|
||||||
timestamp: report.timestamp,
|
|
||||||
findings,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.writeHead(500);
|
res.writeHead(500);
|
||||||
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function handleReportsScans(req, res, ctx) {
|
|
||||||
const date = ctx.parsedUrl.searchParams.get("date");
|
|
||||||
// Regex short-circuit before any filesystem access — blocks path
|
|
||||||
// traversal (`../../etc/passwd` -> 400) and bogus inputs.
|
|
||||||
if (!date || !SCAN_DATE_RE.test(date)) {
|
|
||||||
res.writeHead(400);
|
|
||||||
res.end(JSON.stringify({ error: "Bad request: date=YYYY-MM-DD required" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const reports = readScanReportsForDate(date);
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify({ date, count: reports.length, reports }));
|
|
||||||
} catch (err) {
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve one entry per allowlisted workstation, whether or not it ever reported.
|
|
||||||
// Keeping the neverSeen entries visible is what turns the commissioning window
|
|
||||||
// (deployed, no heartbeat yet) into "agent not installed" on the dashboard
|
|
||||||
// instead of a silently missing card.
|
|
||||||
function handleHostsList(req, res) {
|
|
||||||
const now = Date.now();
|
|
||||||
const hosts = [];
|
|
||||||
|
|
||||||
for (const id of [...HOSTS_ALLOWED_IDS].sort()) {
|
|
||||||
const record = readHostSnapshot(id);
|
|
||||||
if (!record) {
|
|
||||||
hosts.push({ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Freshness is computed server-side from the server-stamped receivedAt —
|
|
||||||
// the client never gets a say in whether it looks online.
|
|
||||||
const ageSeconds = Math.max(
|
|
||||||
0,
|
|
||||||
Math.floor((now - new Date(record.receivedAt).getTime()) / 1000),
|
|
||||||
);
|
|
||||||
hosts.push({
|
|
||||||
id,
|
|
||||||
hostname: record.hostname,
|
|
||||||
uptime: record.uptime,
|
|
||||||
cpu: record.cpu,
|
|
||||||
memory: record.memory,
|
|
||||||
disk: record.disk,
|
|
||||||
receivedAt: record.receivedAt,
|
|
||||||
ageSeconds,
|
|
||||||
online: ageSeconds <= HOSTS_STALE_SECONDS,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify({ staleAfterSeconds: HOSTS_STALE_SECONDS, hosts }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleHostIngest(req, res, ctx) {
|
|
||||||
const id = ctx.hostId;
|
|
||||||
|
|
||||||
// The id already matched HOST_ROUTE_RE, so 403 here means "well-formed but
|
|
||||||
// not one of ours" — the only case that reaches this branch.
|
|
||||||
if (!HOSTS_ALLOWED_IDS.has(id)) {
|
|
||||||
logRejection(req, 403, "host id not in HOSTS_ALLOWED_IDS", id);
|
|
||||||
res.writeHead(403);
|
|
||||||
res.end(JSON.stringify({ error: "Forbidden" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = await readBody(req, res);
|
|
||||||
if (raw === null) return; // 413 already answered, or the socket is gone.
|
|
||||||
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
res.writeHead(400);
|
|
||||||
res.end(JSON.stringify({ error: "Bad request: body must be valid JSON" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = sanitizeSnapshot(payload);
|
|
||||||
if (result.error) {
|
|
||||||
res.writeHead(400);
|
|
||||||
res.end(JSON.stringify({ error: `Bad request: ${result.error}` }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// receivedAt is stamped by the SERVER on arrival. A client-supplied timestamp
|
|
||||||
// would let a stale (or hostile) agent claim to be fresher than it is.
|
|
||||||
const record = { id, ...result.snapshot, receivedAt: new Date().toISOString() };
|
|
||||||
|
|
||||||
try {
|
|
||||||
writeHostSnapshot(id, record);
|
|
||||||
res.removeHeader("Content-Type");
|
|
||||||
res.writeHead(204);
|
|
||||||
res.end();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[hosts] failed to persist snapshot for ${id}:`, err.message);
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end(JSON.stringify({ error: "Internal error" }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Routing ----------------------------------------------------------------
|
|
||||||
|
|
||||||
// Route descriptors. `tokenKind` is the ONLY thing that varies between the read
|
|
||||||
// routes and the ingestion route: authentication itself happens once, in the
|
|
||||||
// single gate below. Adding a route here therefore cannot accidentally ship
|
|
||||||
// unauthenticated — which is exactly what a per-branch check invites.
|
|
||||||
const ROUTES = [
|
|
||||||
{ method: "GET", path: "/health", tokenKind: "read", handle: handleHealth },
|
|
||||||
{ method: "GET", path: "/defenseurs", tokenKind: "read", handle: handleDefenseurs },
|
|
||||||
{ method: "GET", path: "/defenseurs/findings", tokenKind: "read", handle: handleFindings },
|
|
||||||
{ method: "GET", path: "/reports/scans", tokenKind: "read", handle: handleReportsScans },
|
|
||||||
{ method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList },
|
|
||||||
{ method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Deny by default: no descriptor match -> null -> 404.
|
|
||||||
function resolveRoute(method, pathname) {
|
|
||||||
for (const route of ROUTES) {
|
|
||||||
if (route.method !== method) continue;
|
|
||||||
if (route.path !== undefined) {
|
|
||||||
if (route.path === pathname) return { route, hostId: null };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const match = route.pattern.exec(pathname);
|
|
||||||
if (match) return { route, hostId: match[1] };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// THE single authentication checkpoint. Returns null when the caller may
|
|
||||||
// proceed, otherwise the rejection to write. Only the expected token varies
|
|
||||||
// per descriptor.
|
|
||||||
function checkAuth(req, route) {
|
|
||||||
const isIngest = route.tokenKind === "ingest";
|
|
||||||
const expected = isIngest ? HOSTS_INGEST_TOKEN : TOKEN;
|
|
||||||
|
|
||||||
if (!expected) {
|
|
||||||
return isIngest
|
|
||||||
? { status: 503, body: { error: "HOSTS_INGEST_TOKEN not configured" }, reason: "ingest token not configured" }
|
|
||||||
: { status: 401, body: { error: "HEALTH_TOKEN not configured" }, reason: "health token not configured" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const header = req.headers["authorization"];
|
|
||||||
const ok = isIngest
|
|
||||||
? typeof header === "string" &&
|
|
||||||
header.startsWith("Bearer ") &&
|
|
||||||
tokenMatches(header.slice("Bearer ".length), expected)
|
|
||||||
: header === `Bearer ${expected}`;
|
|
||||||
|
|
||||||
if (!ok) {
|
|
||||||
return { status: 401, body: { error: "Unauthorized" }, reason: "missing or invalid bearer token" };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handler(req, res) {
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
|
|
||||||
// Parse the URL so /reports/scans can carry a `?date=` query string. The
|
|
||||||
// placeholder host is required because URL() needs an absolute URL.
|
|
||||||
const parsedUrl = new URL(req.url, "http://localhost");
|
|
||||||
const pathname = parsedUrl.pathname;
|
|
||||||
|
|
||||||
// ORDER IS PART OF THE CONTRACT: routing resolves FIRST, so an unknown path
|
|
||||||
// or a wrong method answers 404 — not 401 — exactly as before this route
|
|
||||||
// table existed. Two tests in __tests__/auth.test.js pin that ordering.
|
|
||||||
const resolved = resolveRoute(req.method, pathname);
|
|
||||||
if (!resolved) {
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end(JSON.stringify({ error: "Not found" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rejection = checkAuth(req, resolved.route);
|
|
||||||
if (rejection) {
|
|
||||||
logRejection(req, rejection.status, rejection.reason, resolved.hostId);
|
|
||||||
res.writeHead(rejection.status);
|
|
||||||
res.end(JSON.stringify(rejection.body));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await resolved.route.handle(req, res, { parsedUrl, hostId: resolved.hostId });
|
|
||||||
} catch (err) {
|
|
||||||
// Safety net: every handler already owns its error paths, this only catches
|
|
||||||
// what none of them anticipated.
|
|
||||||
console.error(`[handler] unhandled error on ${safeLogValue(pathname, 120)}:`, err.message);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end(JSON.stringify({ error: "Internal error" }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
const server = http.createServer(handler);
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`vps-health-api listening on :${PORT}`);
|
console.log(`vps-health-api listening on :${PORT}`);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
handler,
|
|
||||||
allowedSeverities,
|
|
||||||
findLatestReportForAgent,
|
|
||||||
tokenMatches,
|
|
||||||
sanitizeSnapshot,
|
|
||||||
HOST_ID_RE,
|
|
||||||
HOST_ROUTE_RE,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
84
metrics.js
84
metrics.js
|
|
@ -1,84 +0,0 @@
|
||||||
const os = require("node:os");
|
|
||||||
const { execSync } = require("node:child_process");
|
|
||||||
const { setTimeout: delay } = require("node:timers/promises");
|
|
||||||
|
|
||||||
// Host metrics collection (CPU / memory / disk), extracted from index.js so the
|
|
||||||
// same code can be reused by the local workstation agent. Pure host probing:
|
|
||||||
// no HTTP, no config, no side effects — the caller owns the response shape.
|
|
||||||
|
|
||||||
function readProcStat() {
|
|
||||||
try {
|
|
||||||
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
|
|
||||||
const parts = line.split(/\s+/).slice(1).map(Number);
|
|
||||||
const idle = parts[3] + parts[4];
|
|
||||||
const total = parts.reduce((a, b) => a + b, 0);
|
|
||||||
return { idle, total };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCpuPercent() {
|
|
||||||
const t1 = readProcStat();
|
|
||||||
if (!t1) return 0;
|
|
||||||
const { idle: idle1, total: total1 } = t1;
|
|
||||||
|
|
||||||
// Sample over 500ms without blocking the event loop, so other async work
|
|
||||||
// (e.g. the Logto healthcheck) can run concurrently.
|
|
||||||
await delay(500);
|
|
||||||
|
|
||||||
const t2 = readProcStat();
|
|
||||||
if (!t2) return 0;
|
|
||||||
const dIdle = t2.idle - idle1;
|
|
||||||
const dTotal = t2.total - total1;
|
|
||||||
if (dTotal === 0) return 0;
|
|
||||||
return Math.round((1 - dIdle / dTotal) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDisk() {
|
|
||||||
try {
|
|
||||||
// Alpine df doesn't support --output, use standard POSIX format
|
|
||||||
const out = execSync("df -k /", { encoding: "utf-8" });
|
|
||||||
const parts = out.trim().split("\n")[1].trim().split(/\s+/);
|
|
||||||
// df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted
|
|
||||||
const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1);
|
|
||||||
const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1);
|
|
||||||
const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1);
|
|
||||||
const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0;
|
|
||||||
return { totalGB, usedGB, freeGB, usagePercent };
|
|
||||||
} catch {
|
|
||||||
return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect CPU, memory and disk into the `{ cpu, memory, disk }` slice of the
|
|
||||||
// /health payload. Awaiting getCpuPercent() first is deliberate: the 500ms
|
|
||||||
// sample is the only await here, so a caller running this inside a
|
|
||||||
// Promise.all() keeps its other work (the Logto check) fully concurrent.
|
|
||||||
// Everything else is cheap and synchronous.
|
|
||||||
async function collectMetrics() {
|
|
||||||
const cpus = os.cpus();
|
|
||||||
const totalMem = os.totalmem();
|
|
||||||
const freeMem = os.freemem();
|
|
||||||
const usedMem = totalMem - freeMem;
|
|
||||||
|
|
||||||
const cpuUsagePercent = await getCpuPercent();
|
|
||||||
|
|
||||||
return {
|
|
||||||
cpu: {
|
|
||||||
model: cpus[0]?.model?.trim() || "unknown",
|
|
||||||
cores: cpus.length,
|
|
||||||
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
|
|
||||||
usagePercent: cpuUsagePercent,
|
|
||||||
},
|
|
||||||
memory: {
|
|
||||||
totalGB: +(totalMem / 1e9).toFixed(1),
|
|
||||||
usedGB: +(usedMem / 1e9).toFixed(1),
|
|
||||||
freeGB: +(freeMem / 1e9).toFixed(1),
|
|
||||||
usagePercent: Math.round((usedMem / totalMem) * 100),
|
|
||||||
},
|
|
||||||
disk: getDisk(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { getCpuPercent, getDisk, collectMetrics };
|
|
||||||
1428
package-lock.json
generated
1428
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,10 +4,6 @@
|
||||||
"description": "Lightweight VPS health monitoring endpoint",
|
"description": "Lightweight VPS health monitoring endpoint",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js"
|
||||||
"test": "vitest run"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"vitest": "^2.1.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
233
test-curl.sh
233
test-curl.sh
|
|
@ -1,233 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# test-curl.sh — manual smoke test for vps-health-api endpoints.
|
|
||||||
#
|
|
||||||
# Spins up the server against a temporary REPORTS_DIR populated with
|
|
||||||
# scan/run-report fixtures, then runs curl against each endpoint and
|
|
||||||
# checks status codes + payload shape. No test runner installed — this
|
|
||||||
# script is the authoritative regression suite for the GET /reports/scans
|
|
||||||
# endpoint until vitest/jest is added.
|
|
||||||
#
|
|
||||||
# Usage :
|
|
||||||
# bash test-curl.sh
|
|
||||||
#
|
|
||||||
# Exit 0 if all cases pass, exit 1 on first failure (fail-fast).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE_URL="${BASE_URL:-http://localhost:3099}"
|
|
||||||
TOKEN="${TOKEN:-test-token-123}"
|
|
||||||
TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)"
|
|
||||||
trap 'rm -rf "$TMP_DIR"; kill "$SERVER_PID" 2>/dev/null || true' EXIT
|
|
||||||
|
|
||||||
# Fixtures :
|
|
||||||
# - 3 scan reports on 2026-05-07 (booking, simpl-liste, maximus)
|
|
||||||
# - 1 defenseur-auto run report on 2026-05-07 (must be filtered out)
|
|
||||||
# - 1 booking scan report on 2026-05-06 (must be excluded by date filter)
|
|
||||||
# - 1 archived scan report on 2026-05-04 (sergent rotated it post-07:30 UTC)
|
|
||||||
# - 1 archived scan report on 2026-05-07 used to assert top-level priority
|
|
||||||
# when the same filename also exists in REPORTS_DIR (defensive dedupe).
|
|
||||||
mkdir -p "$TMP_DIR/reports"
|
|
||||||
mkdir -p "$TMP_DIR/reports/archive"
|
|
||||||
|
|
||||||
cat > "$TMP_DIR/reports/defenseur-booking_2026-05-07T05-30-11-249Z.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"agent": "defenseur-booking",
|
|
||||||
"timestamp": "2026-05-07T05:30:11.249Z",
|
|
||||||
"project": "la-suite-booking",
|
|
||||||
"checksRun": 16,
|
|
||||||
"checksPassed": 14,
|
|
||||||
"findings": []
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
cat > "$TMP_DIR/reports/defenseur-simpl-liste_2026-05-07T05-32-04-512Z.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"agent": "defenseur-simpl-liste",
|
|
||||||
"timestamp": "2026-05-07T05:32:04.512Z",
|
|
||||||
"project": "simpl-liste",
|
|
||||||
"checksRun": 12,
|
|
||||||
"checksPassed": 11,
|
|
||||||
"findings": []
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
cat > "$TMP_DIR/reports/defenseur-maximus_2026-05-07T05-00-12-100Z.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"agent": "defenseur-maximus",
|
|
||||||
"timestamp": "2026-05-07T05:00:12.100Z",
|
|
||||||
"project": "la-compagnie-maximus",
|
|
||||||
"checksRun": 8,
|
|
||||||
"checksPassed": 8,
|
|
||||||
"findings": []
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
cat > "$TMP_DIR/reports/defenseur-auto_2026-05-07.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"agent": "defenseur-auto",
|
|
||||||
"timestamp": "2026-05-07T07:00:00.000Z",
|
|
||||||
"status": "ok",
|
|
||||||
"actions": [],
|
|
||||||
"skipped": [],
|
|
||||||
"totalCostUsd": 0
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
cat > "$TMP_DIR/reports/defenseur-booking_2026-05-06T05-30-00-000Z.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"agent": "defenseur-booking",
|
|
||||||
"timestamp": "2026-05-06T05:30:00.000Z",
|
|
||||||
"project": "la-suite-booking",
|
|
||||||
"checksRun": 16,
|
|
||||||
"checksPassed": 16,
|
|
||||||
"findings": []
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
# Archived scan report (sergent renameSync at 07:30 UTC moves files here).
|
|
||||||
cat > "$TMP_DIR/reports/archive/defenseur-vps_2026-05-04T05-15-00-000Z.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"agent": "defenseur-vps",
|
|
||||||
"timestamp": "2026-05-04T05:15:00.000Z",
|
|
||||||
"project": "vps",
|
|
||||||
"checksRun": 10,
|
|
||||||
"checksPassed": 10,
|
|
||||||
"findings": []
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
# Same filename present at top-level (already created above) AND in archive/.
|
|
||||||
# Top-level wins (more recent — the archive copy is the stale one). The
|
|
||||||
# archive copy carries agent="defenseur-maximus-STALE" so the dedupe
|
|
||||||
# regression case can detect a leak.
|
|
||||||
cat > "$TMP_DIR/reports/archive/defenseur-maximus_2026-05-07T05-00-12-100Z.json" <<'JSON'
|
|
||||||
{
|
|
||||||
"agent": "defenseur-maximus-STALE",
|
|
||||||
"timestamp": "2026-05-07T05:00:12.100Z",
|
|
||||||
"project": "la-compagnie-maximus",
|
|
||||||
"checksRun": 1,
|
|
||||||
"checksPassed": 0,
|
|
||||||
"findings": []
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
# Boot the server with the temp REPORTS_DIR.
|
|
||||||
PORT=3099 \
|
|
||||||
HEALTH_TOKEN="$TOKEN" \
|
|
||||||
REPORTS_DIR="$TMP_DIR/reports" \
|
|
||||||
LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \
|
|
||||||
node "$(dirname "$0")/index.js" >/dev/null 2>&1 &
|
|
||||||
SERVER_PID=$!
|
|
||||||
|
|
||||||
# Wait for the server to be ready.
|
|
||||||
for _ in {1..50}; do
|
|
||||||
if curl -s -o /dev/null "$BASE_URL/health" 2>/dev/null; then break; fi
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
fail() {
|
|
||||||
echo "FAIL: $1"
|
|
||||||
FAIL=$((FAIL+1))
|
|
||||||
}
|
|
||||||
pass() {
|
|
||||||
echo "PASS: $1"
|
|
||||||
PASS=$((PASS+1))
|
|
||||||
}
|
|
||||||
|
|
||||||
# Case 1 : no auth -> 401
|
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/reports/scans?date=2026-05-07")
|
|
||||||
[[ "$code" == "401" ]] && pass "no-auth -> 401" || fail "no-auth -> got $code"
|
|
||||||
|
|
||||||
# Case 2 : wrong token -> 401
|
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
||||||
-H "Authorization: Bearer wrong" "$BASE_URL/reports/scans?date=2026-05-07")
|
|
||||||
[[ "$code" == "401" ]] && pass "wrong-token -> 401" || fail "wrong-token -> got $code"
|
|
||||||
|
|
||||||
# Case 3 : valid token + date 2026-05-07 -> 200, count=3, sorted asc, no auto report
|
|
||||||
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$BASE_URL/reports/scans?date=2026-05-07")
|
|
||||||
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
||||||
agents=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).reports.map(r=>r.agent).join(","));});')
|
|
||||||
[[ "$count" == "3" ]] && pass "valid date 2026-05-07 -> count=3" || fail "valid date 2026-05-07 -> count=$count"
|
|
||||||
[[ "$agents" == "defenseur-maximus,defenseur-booking,defenseur-simpl-liste" ]] \
|
|
||||||
&& pass "sort by timestamp asc + auto filtered" \
|
|
||||||
|| fail "sort/filter mismatch -> $agents"
|
|
||||||
|
|
||||||
# Case 4 : invalid date format -> 400
|
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
||||||
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans?date=hello")
|
|
||||||
[[ "$code" == "400" ]] && pass "invalid date -> 400" || fail "invalid date -> got $code"
|
|
||||||
|
|
||||||
# Case 5 : missing date param -> 400
|
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
||||||
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans")
|
|
||||||
[[ "$code" == "400" ]] && pass "missing date -> 400" || fail "missing date -> got $code"
|
|
||||||
|
|
||||||
# Case 6 : valid token + unknown date (no fixtures) -> 200 count=0
|
|
||||||
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$BASE_URL/reports/scans?date=2025-01-01")
|
|
||||||
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
||||||
[[ "$count" == "0" ]] && pass "unknown date -> count=0" || fail "unknown date -> count=$count"
|
|
||||||
|
|
||||||
# Case 7 : valid token + date 2026-05-06 -> 200 count=1
|
|
||||||
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$BASE_URL/reports/scans?date=2026-05-06")
|
|
||||||
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
||||||
[[ "$count" == "1" ]] && pass "date 2026-05-06 -> count=1" || fail "date 2026-05-06 -> count=$count"
|
|
||||||
|
|
||||||
# Case 8 : path traversal -> 400 (regex blocks)
|
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$BASE_URL/reports/scans?date=../../../etc/passwd")
|
|
||||||
[[ "$code" == "400" ]] && pass "path traversal -> 400" || fail "path traversal -> got $code"
|
|
||||||
|
|
||||||
# Case 9 : POST -> 404 (GET-only)
|
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$BASE_URL/reports/scans?date=2026-05-07")
|
|
||||||
[[ "$code" == "404" ]] && pass "POST -> 404" || fail "POST -> got $code"
|
|
||||||
|
|
||||||
# Case 10 : wrong path -> 404
|
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
||||||
-H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/nope")
|
|
||||||
[[ "$code" == "404" ]] && pass "wrong path -> 404" || fail "wrong path -> got $code"
|
|
||||||
|
|
||||||
# Case 11 : archive-only date -> 200 count=1, returns the archived report.
|
|
||||||
# Reproduces the post-07:30 UTC window (sergent rotated all reports out of
|
|
||||||
# REPORTS_DIR into REPORTS_DIR/archive).
|
|
||||||
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$BASE_URL/reports/scans?date=2026-05-04")
|
|
||||||
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
||||||
agent=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).reports[0]?.agent||"");});')
|
|
||||||
[[ "$count" == "1" ]] && pass "archive-only date 2026-05-04 -> count=1" || fail "archive-only date 2026-05-04 -> count=$count"
|
|
||||||
[[ "$agent" == "defenseur-vps" ]] && pass "archive report agent matches" || fail "archive report agent mismatch -> $agent"
|
|
||||||
|
|
||||||
# Case 12 : top-level + archive same filename -> top-level wins (defensive
|
|
||||||
# dedupe). The archive copy carries agent="defenseur-maximus-STALE" — if we
|
|
||||||
# see that string in the response we picked the wrong copy.
|
|
||||||
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$BASE_URL/reports/scans?date=2026-05-07")
|
|
||||||
stale=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const a=JSON.parse(s).reports.map(r=>r.agent);console.log(a.includes("defenseur-maximus-STALE")?"yes":"no");});')
|
|
||||||
[[ "$stale" == "no" ]] && pass "top-level priority over archive (no STALE)" || fail "archive copy leaked -> reports include STALE"
|
|
||||||
# Also assert count is still 3 — no duplication of the maximus report.
|
|
||||||
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
||||||
[[ "$count" == "3" ]] && pass "no dedupe duplication on 2026-05-07" || fail "dedupe duplication -> count=$count"
|
|
||||||
|
|
||||||
# Case 13 : missing archive/ subdir is OK (silent skip). Remove the directory
|
|
||||||
# and re-query 2026-05-07 — should still return the 3 top-level reports.
|
|
||||||
rm -rf "$TMP_DIR/reports/archive"
|
|
||||||
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$BASE_URL/reports/scans?date=2026-05-07")
|
|
||||||
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
||||||
[[ "$count" == "3" ]] && pass "missing archive/ -> still count=3 from top-level" || fail "missing archive/ -> count=$count"
|
|
||||||
# And the archive-only date now collapses to 0 silently.
|
|
||||||
body=$(curl -s -H "Authorization: Bearer $TOKEN" \
|
|
||||||
"$BASE_URL/reports/scans?date=2026-05-04")
|
|
||||||
count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});')
|
|
||||||
[[ "$count" == "0" ]] && pass "missing archive/ + archive-only date -> count=0" || fail "missing archive/ archive-only -> count=$count"
|
|
||||||
|
|
||||||
echo
|
|
||||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
|
||||||
[[ "$FAIL" == "0" ]] || exit 1
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
test: {
|
|
||||||
environment: "node",
|
|
||||||
globals: true,
|
|
||||||
include: ["__tests__/**/*.test.js"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Loading…
Reference in a new issue