feat: ingestion et lecture des postes (POST /hosts/<id>, GET /hosts) (#13) #20

Closed
maximus wants to merge 1 commit from issue-13-hosts-endpoints into issue-12-auth-tests
5 changed files with 934 additions and 130 deletions

View file

@ -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

View file

@ -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,9 +56,11 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests ## Tests
- `npm test` (vitest) — 20 cas - `npm test` (vitest) — 61 cas
- `__tests__/findings.test.js``/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) - `__tests__/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__/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` — smoke des endpoints postes (22 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, 413, `neverSeen`, fraicheur serveur, entree corrompue degradee). La matrice exhaustive est l'issue #14.
- Runtime reste 0-dep ; vitest en devDep uniquement - Runtime reste 0-dep ; vitest en devDep uniquement
## Gotchas ## Gotchas
@ -48,7 +68,10 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
- Pas d'Express — HTTP natif Node.js uniquement - Pas d'Express — HTTP natif Node.js uniquement
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que les tests locaux ne voient pas. - 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.
- `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`. - `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`.
- Le `status.json` et `agents-map.json` sont ecrits par le Sergent defenseurs, pas par cette API (read-only) - 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).

View file

@ -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,28 @@ 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.
## Tests ## Tests
``` ```

277
__tests__/hosts.test.js Normal file
View file

@ -0,0 +1,277 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// Smoke coverage for the workstation snapshot surface (issue #13).
//
// This is deliberately NOT the exhaustive matrix — that is issue #14. What is
// pinned here is the handful of decisions that would be expensive to get wrong
// and silent to regress: the routing-before-auth ordering, the narrow host id
// regex as the traversal control, 401 winning over 403, the whitelist rebuild,
// and the server-stamped freshness.
const TOKEN = "test-token";
const INGEST_TOKEN = "test-ingest-token";
let tmpDir;
let hostsDir;
let server;
let baseUrl;
let handler;
function validSnapshot() {
return {
hostname: "thinkpad-x1",
uptime: 4242,
cpu: { model: "Intel Core i7", cores: 8, loadAvg: [0.5, 0.4, 0.3], usagePercent: 12 },
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
};
}
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 request(route, { method = "GET", auth, body } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`${baseUrl}${route}`, { method, headers, body });
const parsed = await res.json().catch(() => ({}));
return { status: res.status, body: parsed };
}
function ingest(route, { auth = `Bearer ${INGEST_TOKEN}`, body = JSON.stringify(validSnapshot()) } = {}) {
return request(route, { method: "POST", auth, body });
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-hosts-test-"));
hostsDir = path.join(tmpDir, "hosts");
process.env.HEALTH_TOKEN = TOKEN;
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
process.env.HOSTS_DIR = hostsDir;
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
delete process.env.HOSTS_STALE_SECONDS;
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");
await startServer();
});
afterEach(async () => {
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
delete process.env.HOSTS_INGEST_TOKEN;
delete process.env.HOSTS_DIR;
delete process.env.HOSTS_ALLOWED_IDS;
delete process.env.HOSTS_STALE_SECONDS;
});
describe("POST /hosts/<id> — routing is the traversal control", () => {
// Every one of these must be 404, not 400/403: the request never reaches a
// handler. URL() normalises the traversal away, the rest fail the regex.
const REJECTED = [
["dot-dot traversal", "/hosts/../../etc/passwd"],
["encoded traversal", "/hosts/..%2f..%2fetc%2fpasswd"],
["uppercase id", "/hosts/THINKPAD"],
["id with a slash", "/hosts/thinkpad/extra"],
["empty id", "/hosts/"],
];
test.each(REJECTED)("404 on POST %s", async (_label, route) => {
const { status, body } = await ingest(route);
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
test("404 on GET /hosts/thinkpad — the id route is POST-only", async () => {
const { status } = await request("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` });
expect(status).toBe(404);
});
});
describe("POST /hosts/<id> — auth gate", () => {
test("401 with no Authorization header", async () => {
const { status, body } = await ingest("/hosts/thinkpad", { auth: null });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 when presenting the read token instead of the ingest token", async () => {
const { status } = await ingest("/hosts/thinkpad", { auth: `Bearer ${TOKEN}` });
expect(status).toBe(401);
});
// 401 must win over 403 so an unauthenticated caller cannot probe the
// allowlist for valid workstation ids.
test("401 (not 403) on a non-allowlisted id without a token", async () => {
const { status } = await ingest("/hosts/popos", { auth: null });
expect(status).toBe(401);
});
test("403 on a well-formed id outside the allowlist", async () => {
const { status, body } = await ingest("/hosts/popos");
expect(status).toBe(403);
expect(body.error).toBe("Forbidden");
});
test("503 when HOSTS_INGEST_TOKEN is unset (fail-closed)", async () => {
delete process.env.HOSTS_INGEST_TOKEN;
await stopServer();
await startServer();
const { status, body } = await ingest("/hosts/thinkpad");
expect(status).toBe(503);
expect(body.error).toBe("HOSTS_INGEST_TOKEN not configured");
});
});
describe("POST /hosts/<id> — body handling", () => {
test("204 and an atomically written file on a valid snapshot", async () => {
const { status } = await ingest("/hosts/thinkpad");
expect(status).toBe(204);
const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8"));
expect(written.hostname).toBe("thinkpad-x1");
expect(typeof written.receivedAt).toBe("string");
// No leftover temp file next to the snapshot.
expect(fs.readdirSync(hostsDir)).toEqual(["thinkpad.json"]);
});
test("the persisted object is rebuilt from the whitelist, not stored verbatim", async () => {
const raw = `{"__proto__":{"polluted":true},"extra":"nope","receivedAt":"1999-01-01T00:00:00.000Z",${JSON.stringify(
{ ...validSnapshot(), hostname: "h".repeat(300) },
).slice(1)}`;
const { status } = await ingest("/hosts/thinkpad", { body: raw });
expect(status).toBe(204);
const written = JSON.parse(fs.readFileSync(path.join(hostsDir, "thinkpad.json"), "utf-8"));
expect(Object.keys(written)).toEqual([
"id",
"hostname",
"uptime",
"cpu",
"memory",
"disk",
"receivedAt",
]);
expect(written.extra).toBeUndefined();
expect(written.polluted).toBeUndefined();
expect({}.polluted).toBeUndefined();
// Strings capped at 128 chars, timestamp stamped by the server.
expect(written.hostname.length).toBe(128);
expect(written.receivedAt).not.toBe("1999-01-01T00:00:00.000Z");
expect(Date.now() - new Date(written.receivedAt).getTime()).toBeLessThan(10000);
});
test("400 on unparsable JSON", async () => {
const { status } = await ingest("/hosts/thinkpad", { body: "{not json" });
expect(status).toBe(400);
});
test("400 on a missing or mistyped field", async () => {
const payload = validSnapshot();
delete payload.memory;
const { status, body } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) });
expect(status).toBe(400);
expect(body.error).toMatch(/memory/);
expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false);
});
test("413 past 4 KiB, and nothing is written", async () => {
const payload = { ...validSnapshot(), padding: "x".repeat(5000) };
let status;
try {
({ status } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) }));
} catch {
// The socket is destroyed right after the 413 flushes; a client that
// loses the race sees a transport error instead. Either way the write
// must not have happened, which is what the assertion below pins.
status = 413;
}
expect(status).toBe(413);
expect(fs.existsSync(path.join(hostsDir, "thinkpad.json"))).toBe(false);
});
});
describe("GET /hosts", () => {
test("401 when presenting the ingest token instead of the read token", async () => {
const { status } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` });
expect(status).toBe(401);
});
test("reports an allowlisted host that never checked in", async () => {
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
expect(status).toBe(200);
expect(body.staleAfterSeconds).toBe(900);
expect(body.hosts).toHaveLength(1);
expect(body.hosts[0]).toMatchObject({ id: "thinkpad", neverSeen: true, online: false });
});
test("serves the snapshot back with a server-computed freshness", async () => {
await ingest("/hosts/thinkpad");
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
expect(status).toBe(200);
const host = body.hosts[0];
expect(host.neverSeen).toBeUndefined();
expect(host.hostname).toBe("thinkpad-x1");
expect(host.ageSeconds).toBeLessThan(5);
expect(host.online).toBe(true);
expect(Object.keys(host.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
});
test("online flips to false past HOSTS_STALE_SECONDS", async () => {
fs.mkdirSync(hostsDir, { recursive: true });
const stale = {
id: "thinkpad",
...validSnapshot(),
receivedAt: new Date(Date.now() - 3600 * 1000).toISOString(),
};
fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), JSON.stringify(stale));
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
expect(body.hosts[0].online).toBe(false);
expect(body.hosts[0].ageSeconds).toBeGreaterThan(3000);
});
test("degrades a corrupted snapshot instead of failing the whole response", async () => {
fs.mkdirSync(hostsDir, { recursive: true });
fs.writeFileSync(path.join(hostsDir, "thinkpad.json"), "{ truncated");
const { status, body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
expect(status).toBe(200);
expect(body.hosts).toHaveLength(1);
expect(body.hosts[0].neverSeen).toBe(true);
});
});
describe("HOSTS_ALLOWED_IDS validation", () => {
test("entries that are not valid host ids are dropped at startup", async () => {
process.env.HOSTS_ALLOWED_IDS = "thinkpad, ../defenseurs/status ,POPOS,,popos";
await stopServer();
await startServer();
const { body } = await request("/hosts", { auth: `Bearer ${TOKEN}` });
expect(body.hosts.map((h) => h.id)).toEqual(["popos", "thinkpad"]);
});
});

647
index.js
View file

@ -1,7 +1,16 @@
const http = require("node:http"); const http = require("node:http");
const os = require("node:os"); const os = require("node:os");
const { readFileSync, readdirSync, existsSync } = require("node:fs"); const {
readFileSync,
readdirSync,
existsSync,
mkdirSync,
writeFileSync,
renameSync,
unlinkSync,
} = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const { createHash, timingSafeEqual } = require("node:crypto");
const { collectMetrics } = require("./metrics.js"); const { collectMetrics } = require("./metrics.js");
const PORT = parseInt(process.env.PORT || "3001", 10); const PORT = parseInt(process.env.PORT || "3001", 10);
@ -19,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)
@ -37,6 +92,10 @@ if (!TOKEN) {
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed)."); console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
} }
// No startup warning for a missing HOSTS_INGEST_TOKEN: the ingestion path
// fail-closes to 503 and logs the reason on the request that hits it, which is
// the moment an operator actually needs to see it.
async function getLogtoHealth() { async function getLogtoHealth() {
const ac = new AbortController(); const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), LOGTO_TIMEOUT_MS); const timer = setTimeout(() => ac.abort(), LOGTO_TIMEOUT_MS);
@ -178,130 +237,215 @@ async function getHealth() {
}; };
} }
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);
@ -312,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, () => {
@ -319,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,
};