Compare commits
6 commits
39a55fad93
...
e181a9691c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e181a9691c | ||
|
|
e24f5b6f00 | ||
|
|
56ee580167 | ||
|
|
fedb1c81dc | ||
|
|
babfd1f4b9 | ||
|
|
79cb813767 |
13 changed files with 3332 additions and 197 deletions
16
.env.example
16
.env.example
|
|
@ -7,3 +7,19 @@ LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-con
|
||||||
# Directory served by GET /reports/scans. Bind-mount target on Coolify —
|
# Directory served by GET /reports/scans. Bind-mount target on Coolify —
|
||||||
# parent /data/defenseurs/ is already mounted (status.json sits next to it).
|
# parent /data/defenseurs/ is already mounted (status.json sits next to it).
|
||||||
REPORTS_DIR=/data/defenseurs/reports
|
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
|
||||||
|
|
|
||||||
47
CLAUDE.md
47
CLAUDE.md
|
|
@ -1,6 +1,6 @@
|
||||||
# VPS Health API
|
# VPS Health API
|
||||||
|
|
||||||
API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
|
API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
|
@ -9,11 +9,24 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
|
||||||
- `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 /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 /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"`.
|
||||||
|
|
||||||
## Auth
|
- `GET /hosts` — dernier snapshot de chaque poste de l'allowlist. Format `{ staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }`. `online = ageSeconds <= HOSTS_STALE_SECONDS` (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en `{ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }` sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel.
|
||||||
|
- `POST /hosts/<id>` — **seule surface d'ecriture de l'API**. Auth par `HOSTS_INGEST_TOKEN` (pas `HEALTH_TOKEN`). Corps = payload de `GET /health` moins `logto` et `timestamp`. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 `<id>` bien forme mais hors allowlist / 404 `<id>` hors format / 413 corps > 4 Kio (connexion coupee) / 503 `HOSTS_INGEST_TOKEN` non configure. Exemple : `curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad"`.
|
||||||
|
|
||||||
- Bearer token via env `HEALTH_TOKEN`
|
## Routage et auth
|
||||||
- Fail-closed : si `HEALTH_TOKEN` non configure, toutes les requetes sont refusees
|
|
||||||
- **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".
|
Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` :
|
||||||
|
|
||||||
|
1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token.
|
||||||
|
2. `checkAuth()` — **un seul point de controle**, dont seul le token attendu varie (`tokenKind: "read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee.
|
||||||
|
3. Dispatch vers le handler.
|
||||||
|
|
||||||
|
Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord.
|
||||||
|
|
||||||
|
- Fail-closed : `HEALTH_TOKEN` absent -> 401 sur les lectures ; `HOSTS_INGEST_TOKEN` absent -> 503 sur l'ingestion
|
||||||
|
- `tokenMatches()` (`timingSafeEqual` sur empreintes SHA-256) protege **uniquement** le chemin d'ingestion. Le realignement du `HEALTH_TOKEN` existant est suivi separement (issue #17) : il toucherait tous les chemins de lecture en production
|
||||||
|
- 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist
|
||||||
|
- Chaque rejet 401/403 est journalise (`[auth] <code> <method> <url> ip=<X-Real-IP> id=<host> reason=<motif>`), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log
|
||||||
|
- **Coolify** : `HEALTH_TOKEN` et `HOSTS_INGEST_TOKEN` doivent etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
|
|
@ -21,9 +34,14 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
|
||||||
- `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`)
|
- `REPORTS_DIR` : dossier lu par `/reports/scans` et `/defenseurs/findings` (default `/data/defenseurs/reports`)
|
||||||
- `DEFENSEURS_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`)
|
- `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) :
|
- 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.
|
- `/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).
|
- `/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
|
||||||
|
|
||||||
|
|
@ -38,13 +56,24 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- `npm test` (vitest) — couvre `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
|
- `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
|
- 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 `status.json` et `agents-map.json` sont ecrits par le Sergent defenseurs, pas par cette API (read-only)
|
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que les tests locaux ne voient pas. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais.
|
||||||
|
- `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env.
|
||||||
|
- `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`.
|
||||||
|
- L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/<id>` ecrit dans `HOSTS_DIR`. Consequence : ce montage-la doit etre en lecture-ecriture pour l'uid 1000, contrairement aux montages `/data/defenseurs`.
|
||||||
|
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync` — `rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct.
|
||||||
|
- L'objet persiste est **reconstruit champ par champ** depuis une liste blanche (nombres via `Number.isFinite`, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par `GET /hosts` et finit dans l'arbre React de l'admin : une cle `__proto__` ou un `hostname` de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload.
|
||||||
|
- Le plafond de corps a 4 Kio compte les octets **recus**, pas le `Content-Length` : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis `req.destroy()` une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code).
|
||||||
- `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500.
|
- `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`.
|
- 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).
|
- 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 ./
|
COPY package.json index.js metrics.js ./
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
USER node
|
USER node
|
||||||
CMD ["node", "index.js"]
|
CMD ["node", "index.js"]
|
||||||
|
|
|
||||||
90
README.md
90
README.md
|
|
@ -4,14 +4,68 @@ Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtim
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
All endpoints require `Authorization: Bearer $HEALTH_TOKEN`.
|
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 | Description |
|
| Method | Path | Token | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------|-------------|
|
||||||
| GET | `/health` | CPU, memory, disk, uptime, Logto reachability |
|
| GET | `/health` | `HEALTH_TOKEN` | CPU, memory, disk, uptime, Logto reachability |
|
||||||
| GET | `/defenseurs` | Defenseurs executive status (status.json) |
|
| GET | `/defenseurs` | `HEALTH_TOKEN` | Defenseurs executive status (status.json) |
|
||||||
| GET | `/defenseurs/findings?project=X` | Detailed findings for a project's Defenseur |
|
| GET | `/defenseurs/findings?project=X` | `HEALTH_TOKEN` | Detailed findings for a project's Defenseur |
|
||||||
| GET | `/reports/scans?date=YYYY-MM-DD` | Aggregated scan reports for a UTC date |
|
| 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`
|
### `GET /defenseurs/findings`
|
||||||
|
|
||||||
|
|
@ -45,17 +99,35 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \
|
||||||
| Env var | Default | Purpose |
|
| Env var | Default | Purpose |
|
||||||
|---------|---------|---------|
|
|---------|---------|---------|
|
||||||
| `PORT` | `3001` | HTTP port |
|
| `PORT` | `3001` | HTTP port |
|
||||||
| `HEALTH_TOKEN` | — | Bearer token (fail-closed if missing) |
|
| `HEALTH_TOKEN` | — | Bearer token for the read routes (fail-closed if missing) |
|
||||||
| `REPORTS_DIR` | `/data/defenseurs/reports` | Scan reports dir |
|
| `REPORTS_DIR` | `/data/defenseurs/reports` | Scan reports dir |
|
||||||
| `DEFENSEURS_AGENTS_MAP_PATH` | `/data/defenseurs/agents-map.json` | Project -> agent snapshot |
|
| `DEFENSEURS_AGENTS_MAP_PATH` | `/data/defenseurs/agents-map.json` | Project -> agent snapshot |
|
||||||
| `LOGTO_HEALTH_URL` | auth.lacompagniemaximus.com | Logto OIDC discovery URL |
|
| `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)
|
## 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/status.json` -> `/data/defenseurs/status.json`
|
||||||
- `/home/defenseur/defenseurs/reports/` -> `/data/defenseurs/reports/`
|
- `/home/defenseur/defenseurs/reports/` -> `/data/defenseurs/reports/`
|
||||||
- `/home/defenseur/defenseurs/agents-map.json` -> `/data/defenseurs/agents-map.json`
|
- `/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
|
## Tests
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
597
__tests__/agent.test.js
Normal file
597
__tests__/agent.test.js
Normal file
|
|
@ -0,0 +1,597 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
201
__tests__/auth.test.js
Normal file
201
__tests__/auth.test.js
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
139
__tests__/health.test.js
Normal file
139
__tests__/health.test.js
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
1078
__tests__/hosts.test.js
Normal file
1078
__tests__/hosts.test.js
Normal file
File diff suppressed because it is too large
Load diff
177
agent/README.md
Normal file
177
agent/README.md
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
# 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).
|
||||||
309
agent/push-metrics.js
Normal file
309
agent/push-metrics.js
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
#!/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,
|
||||||
|
};
|
||||||
70
agent/run-push.sh
Executable file
70
agent/run-push.sh
Executable file
|
|
@ -0,0 +1,70 @@
|
||||||
|
#!/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" "$@"
|
||||||
719
index.js
719
index.js
|
|
@ -1,9 +1,17 @@
|
||||||
const http = require("node:http");
|
const http = require("node:http");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const { execSync } = require("node:child_process");
|
const {
|
||||||
const { readFileSync, readdirSync, existsSync } = require("node:fs");
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
renameSync,
|
||||||
|
unlinkSync,
|
||||||
|
} = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { setTimeout: delay } = require("node:timers/promises");
|
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;
|
||||||
|
|
@ -20,6 +28,52 @@ const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
|
||||||
const VALID_SEVERITIES = Object.keys(SEVERITY_RANK);
|
const VALID_SEVERITIES = Object.keys(SEVERITY_RANK);
|
||||||
const VALID_CATEGORIES = ["deps", "secrets", "code", "acces", "infra"];
|
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):
|
// Severity filter. Asymmetric rule (issue #3):
|
||||||
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
|
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
|
||||||
// - threshold "INFO" -> INFO only (explicit opt-in)
|
// - threshold "INFO" -> INFO only (explicit opt-in)
|
||||||
|
|
@ -38,34 +92,9 @@ 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).");
|
||||||
}
|
}
|
||||||
|
|
||||||
function readProcStat() {
|
// No startup warning for a missing HOSTS_INGEST_TOKEN: the ingestion path
|
||||||
try {
|
// fail-closes to 503 and logs the reason on the request that hits it, which is
|
||||||
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
|
// the moment an operator actually needs to see it.
|
||||||
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();
|
||||||
|
|
@ -85,22 +114,6 @@ async function getLogtoHealth() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reproduce the isScanReport guard from defenseurs/src/report.ts. The
|
// Reproduce the isScanReport guard from defenseurs/src/report.ts. The
|
||||||
// defenseur-auto run report has shape { actions[], skipped[] } with no
|
// defenseur-auto run report has shape { actions[], skipped[] } with no
|
||||||
// findings[] — it must be filtered out so it never reaches the auto pipeline
|
// findings[] — it must be filtered out so it never reaches the auto pipeline
|
||||||
|
|
@ -207,13 +220,11 @@ function findLatestReportForAgent(agent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getHealth() {
|
async function getHealth() {
|
||||||
const cpus = os.cpus();
|
// Keep the Promise.all: the 500ms CPU sample inside collectMetrics() and the
|
||||||
const totalMem = os.totalmem();
|
// up-to-3s Logto check must stay concurrent. Awaiting them one after the
|
||||||
const freeMem = os.freemem();
|
// other would push the p99 of /health to ~3.5s.
|
||||||
const usedMem = totalMem - freeMem;
|
const [metrics, logto] = await Promise.all([
|
||||||
|
collectMetrics(),
|
||||||
const [cpuUsagePercent, logto] = await Promise.all([
|
|
||||||
getCpuPercent(),
|
|
||||||
getLogtoHealth(),
|
getLogtoHealth(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -221,147 +232,220 @@ 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()),
|
||||||
cpu: {
|
...metrics,
|
||||||
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handler(req, res) {
|
// --- Hosts helpers ----------------------------------------------------------
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
|
|
||||||
// Parse the URL so /reports/scans can carry a `?date=` query string. The
|
// Printable-ASCII-only, length-capped rendering for anything attacker-supplied
|
||||||
// placeholder host is required because URL() needs an absolute URL.
|
// that reaches a log line (X-Real-IP, host id, env entries). Keeps a crafted
|
||||||
const parsedUrl = new URL(req.url, "http://localhost");
|
// value from forging extra log records.
|
||||||
const pathname = parsedUrl.pathname;
|
function safeLogValue(value, max = 64) {
|
||||||
|
return String(value).replace(/[^\x20-\x7e]/g, "?").slice(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
const validRoutes = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans"];
|
// Log every 401/403. This is the first publicly writable surface on the API:
|
||||||
if (req.method !== "GET" || !validRoutes.includes(pathname)) {
|
// without a trace, a token-guessing campaign would leave nothing behind.
|
||||||
res.writeHead(404);
|
function logRejection(req, status, reason, id) {
|
||||||
res.end(JSON.stringify({ error: "Not found" }));
|
const ip = safeLogValue(req.headers["x-real-ip"] || req.socket?.remoteAddress || "unknown");
|
||||||
return;
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!TOKEN) {
|
// Constant-time bearer comparison for the ingestion path. Both sides are hashed
|
||||||
res.writeHead(401);
|
// first, so the buffers are always 32 bytes: timingSafeEqual never throws on
|
||||||
res.end(JSON.stringify({ error: "HEALTH_TOKEN not configured" }));
|
// mismatched lengths, and the length of the real token cannot leak.
|
||||||
return;
|
//
|
||||||
}
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
const auth = req.headers["authorization"];
|
// Read the request body with a hard 4 KiB ceiling. The counter runs on the
|
||||||
if (auth !== `Bearer ${TOKEN}`) {
|
// bytes actually received, never on Content-Length: a client can lie in the
|
||||||
res.writeHead(401);
|
// header, and chunked transfer-encoding omits it altogether.
|
||||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
//
|
||||||
return;
|
// 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;
|
||||||
|
|
||||||
if (pathname === "/defenseurs") {
|
req.on("data", (chunk) => {
|
||||||
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
|
if (settled) return;
|
||||||
try {
|
size += chunk.length;
|
||||||
const status = readFileSync(statusPath, "utf-8");
|
if (size > MAX_BODY_BYTES) {
|
||||||
res.writeHead(200);
|
settled = true;
|
||||||
res.end(status);
|
req.pause();
|
||||||
} catch {
|
res.writeHead(413);
|
||||||
res.writeHead(200);
|
// Destroy only once the 413 has been flushed: req.destroy() tears down
|
||||||
res.end(JSON.stringify({ status: "no_data" }));
|
// the socket, so cutting first would leave the client with a network
|
||||||
}
|
// error instead of a status code.
|
||||||
return;
|
res.end(JSON.stringify({ error: "Payload too large" }), () => req.destroy());
|
||||||
}
|
resolve(null);
|
||||||
|
|
||||||
if (pathname === "/defenseurs/findings") {
|
|
||||||
const project = parsedUrl.searchParams.get("project");
|
|
||||||
const category = parsedUrl.searchParams.get("category");
|
|
||||||
const severity = 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const report = findLatestReportForAgent(agent);
|
|
||||||
if (!report) {
|
|
||||||
res.writeHead(200);
|
|
||||||
res.end(JSON.stringify({ findings: [], status: "no_data" }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const allowed = new Set(allowedSeverities(severity));
|
chunks.push(chunk);
|
||||||
const findings = report.findings.filter(
|
});
|
||||||
(f) => allowed.has(f.severity) && (!category || f.category === category),
|
|
||||||
);
|
req.on("end", () => {
|
||||||
res.writeHead(200);
|
if (settled) return;
|
||||||
res.end(JSON.stringify({
|
settled = true;
|
||||||
agent,
|
resolve(Buffer.concat(chunks).toString("utf-8"));
|
||||||
project,
|
});
|
||||||
timestamp: report.timestamp,
|
|
||||||
findings,
|
req.on("error", () => {
|
||||||
}));
|
if (settled) return;
|
||||||
} catch (err) {
|
settled = true;
|
||||||
res.writeHead(500);
|
resolve(null);
|
||||||
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
});
|
||||||
}
|
});
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
function cleanString(value) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
return value.slice(0, MAX_STRING_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanNumber(value) {
|
||||||
|
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" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/reports/scans") {
|
const hostname = cleanString(payload.hostname);
|
||||||
const date = parsedUrl.searchParams.get("date");
|
if (hostname === null) return { error: "hostname must be a string" };
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const reports = readScanReportsForDate(date);
|
unlinkSync(tmpPath);
|
||||||
res.writeHead(200);
|
} catch {
|
||||||
res.end(JSON.stringify({ date, count: reports.length, reports }));
|
// Best effort — the temp file may never have been created.
|
||||||
} catch (err) {
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
|
||||||
}
|
}
|
||||||
return;
|
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 {
|
try {
|
||||||
const data = await getHealth();
|
const data = await getHealth();
|
||||||
res.writeHead(200);
|
res.writeHead(200);
|
||||||
|
|
@ -372,6 +456,277 @@ async function handler(req, res) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDefenseurs(req, res) {
|
||||||
|
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
|
||||||
|
try {
|
||||||
|
const status = readFileSync(statusPath, "utf-8");
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(status);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(200);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = findLatestReportForAgent(agent);
|
||||||
|
if (!report) {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end(JSON.stringify({ findings: [], status: "no_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) {
|
||||||
|
res.writeHead(500);
|
||||||
|
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) {
|
if (require.main === module) {
|
||||||
const server = http.createServer(handler);
|
const server = http.createServer(handler);
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
|
|
@ -379,4 +734,12 @@ if (require.main === module) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handler, allowedSeverities, findLatestReportForAgent };
|
module.exports = {
|
||||||
|
handler,
|
||||||
|
allowedSeverities,
|
||||||
|
findLatestReportForAgent,
|
||||||
|
tokenMatches,
|
||||||
|
sanitizeSnapshot,
|
||||||
|
HOST_ID_RE,
|
||||||
|
HOST_ROUTE_RE,
|
||||||
|
};
|
||||||
|
|
|
||||||
84
metrics.js
Normal file
84
metrics.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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 };
|
||||||
Loading…
Reference in a new issue