feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts)
Open the API's first write surface. Workstations push their CPU / memory /
disk snapshot, the admin dashboard reads it back with a server-computed
freshness.
Routing moves from an exact path list to a descriptor table, keeping ONE
authentication checkpoint:
resolveRoute() -> no match means an immediate 404, deny by default
checkAuth() -> the single gate; only the expected token varies per
descriptor (HEALTH_TOKEN for reads, HOSTS_INGEST_TOKEN
for ingestion)
dispatch
The routing-before-auth ordering is preserved on purpose: an unknown path or
a wrong method still answers 404, never 401, exactly as before. The two
`404 (not 401)` tests added in #12 pin that ordering and still pass.
Security controls on the new write path:
- The route regex ^/hosts/([a-z0-9][a-z0-9-]{0,31})$, POST-only, IS the path
traversal control. URL() normalises /hosts/../../etc/passwd to /etc/passwd
and encoded traversals fail the match, so every hostile id lands on 404.
403 is reserved for well-formed ids outside the allowlist.
- 401 wins over 403, so an unauthenticated caller cannot probe the allowlist.
- tokenMatches() compares SHA-256 digests with timingSafeEqual. Scoped to the
ingestion path only; realigning HEALTH_TOKEN touches every read route in
production and is tracked separately.
- Body capped at 4 KiB by counting received bytes, never Content-Length:
a client can lie in the header and chunked encoding omits it. Past the cap,
413 then req.destroy() once the response has flushed.
- The persisted object is rebuilt field by field from a whitelist — finite
numbers, strings capped at 128 chars, everything else dropped — because the
file is read back by GET /hosts and ends up in the admin React tree. The
read path re-runs the same whitelist: the bind-mount is writable, so the
file on disk earns no more trust than the payload did.
- Snapshots are written to a temp file in the same directory then renamed, so
a concurrent GET /hosts can never observe a truncated JSON.
- HOSTS_ALLOWED_IDS entries are validated at startup with the same regex;
rejects are logged and dropped rather than becoming file paths.
- Every 401/403 is logged with X-Real-IP, the host id and the reason. Log
values are filtered to printable ASCII so a crafted header cannot forge
extra log lines.
- receivedAt is stamped by the server on arrival; a client-supplied timestamp
is discarded by the whitelist. online = ageSeconds <= HOSTS_STALE_SECONDS.
Runtime stays zero-dependency — node:crypto is a builtin and no new module
file was added, so the Dockerfile's explicit COPY list is unchanged.
Tests: 61 (39 existing untouched + 22 new). __tests__/hosts.test.js is smoke
coverage of the decisions that would be silent to regress; the exhaustive
matrix is issue #14.
Docs: .env.example gains HOSTS_DIR / HOSTS_ALLOWED_IDS / HOSTS_INGEST_TOKEN /
HOSTS_STALE_SECONDS; CLAUDE.md and README.md document the endpoints, the
routing/auth ordering and the config. The CLAUDE.md "read-only" gotcha is
corrected — the API now writes, and HOSTS_DIR must be writable by uid 1000.
Resolves #13
This commit is contained in:
parent
babfd1f4b9
commit
fedb1c81dc
5 changed files with 934 additions and 130 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 —
|
||||
# parent /data/defenseurs/ is already mounted (status.json sits next to it).
|
||||
REPORTS_DIR=/data/defenseurs/reports
|
||||
|
||||
# --- Workstation snapshots (POST /hosts/<id>, GET /hosts) --------------------
|
||||
# Directory the API WRITES workstation snapshots into, one <id>.json per host.
|
||||
# Unlike the /data/defenseurs mounts this one must be writable by uid 1000
|
||||
# (the `node` user the container runs as) or every ingest answers 500.
|
||||
HOSTS_DIR=/data/hosts
|
||||
# Comma-separated allowlist of host ids. Each entry must match
|
||||
# ^[a-z0-9][a-z0-9-]{0,31}$ — anything else is logged and dropped at startup.
|
||||
HOSTS_ALLOWED_IDS=thinkpad
|
||||
# Bearer token for POST /hosts/<id>. Separate from HEALTH_TOKEN on purpose: a
|
||||
# workstation agent should be able to write its own snapshot without gaining
|
||||
# read access to the Defenseurs reports. Same Coolify rule as HEALTH_TOKEN —
|
||||
# is_runtime=true, is_buildtime=false. Unset -> POST /hosts/<id> answers 503.
|
||||
HOSTS_INGEST_TOKEN=change-me-to-a-strong-secret
|
||||
# Age (seconds) past which GET /hosts reports a host as offline. Default 900.
|
||||
HOSTS_STALE_SECONDS=900
|
||||
|
|
|
|||
41
CLAUDE.md
41
CLAUDE.md
|
|
@ -1,6 +1,6 @@
|
|||
# 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
|
||||
|
||||
|
|
@ -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 /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`
|
||||
- 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".
|
||||
## Routage et auth
|
||||
|
||||
Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` :
|
||||
|
||||
1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token.
|
||||
2. `checkAuth()` — **un seul point de controle**, dont seul le token attendu varie (`tokenKind: "read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee.
|
||||
3. Dispatch vers le handler.
|
||||
|
||||
Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord.
|
||||
|
||||
- Fail-closed : `HEALTH_TOKEN` absent -> 401 sur les lectures ; `HOSTS_INGEST_TOKEN` absent -> 503 sur l'ingestion
|
||||
- `tokenMatches()` (`timingSafeEqual` sur empreintes SHA-256) protege **uniquement** le chemin d'ingestion. Le realignement du `HEALTH_TOKEN` existant est suivi separement (issue #17) : il toucherait tous les chemins de lecture en production
|
||||
- 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist
|
||||
- Chaque rejet 401/403 est journalise (`[auth] <code> <method> <url> ip=<X-Real-IP> id=<host> reason=<motif>`), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log
|
||||
- **Coolify** : `HEALTH_TOKEN` et `HOSTS_INGEST_TOKEN` doivent etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
|
||||
|
||||
## Config
|
||||
|
||||
|
|
@ -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)
|
||||
- `REPORTS_DIR` : dossier lu par `/reports/scans` et `/defenseurs/findings` (default `/data/defenseurs/reports`)
|
||||
- `DEFENSEURS_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`)
|
||||
- `HOSTS_DIR` : dossier **ecrit** par `POST /hosts/<id>`, un `<id>.json` par poste (default `/data/hosts`)
|
||||
- `HOSTS_ALLOWED_IDS` : allowlist separee par virgules (default `thinkpad` — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee
|
||||
- `HOSTS_INGEST_TOKEN` : token d'ingestion, distinct de `HEALTH_TOKEN` (pas de default — absent = 503)
|
||||
- `HOSTS_STALE_SECONDS` : age au-dela duquel un poste est reporte hors ligne (default 900)
|
||||
- Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) :
|
||||
- `/data/defenseurs` (host) -> `/data/defenseurs` : `status.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne.
|
||||
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10).
|
||||
- `/data/defenseurs` (host) -> `/data/defenseurs` : `status.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Lecture seule** pour cette API.
|
||||
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10). **Lecture seule** pour cette API.
|
||||
- `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500.
|
||||
|
||||
## Deploy
|
||||
|
||||
|
|
@ -38,9 +56,11 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|||
|
||||
## 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__/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
|
||||
|
||||
## Gotchas
|
||||
|
|
@ -48,7 +68,10 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|||
- 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.
|
||||
- `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.
|
||||
- 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).
|
||||
|
|
|
|||
83
README.md
83
README.md
|
|
@ -4,14 +4,68 @@ Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtim
|
|||
|
||||
## 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 |
|
||||
|--------|------|-------------|
|
||||
| GET | `/health` | CPU, memory, disk, uptime, Logto reachability |
|
||||
| GET | `/defenseurs` | Defenseurs executive status (status.json) |
|
||||
| GET | `/defenseurs/findings?project=X` | Detailed findings for a project's Defenseur |
|
||||
| GET | `/reports/scans?date=YYYY-MM-DD` | Aggregated scan reports for a UTC date |
|
||||
| Method | Path | Token | Description |
|
||||
|--------|------|-------|-------------|
|
||||
| GET | `/health` | `HEALTH_TOKEN` | CPU, memory, disk, uptime, Logto reachability |
|
||||
| GET | `/defenseurs` | `HEALTH_TOKEN` | Defenseurs executive status (status.json) |
|
||||
| GET | `/defenseurs/findings?project=X` | `HEALTH_TOKEN` | Detailed findings for a project's Defenseur |
|
||||
| GET | `/reports/scans?date=YYYY-MM-DD` | `HEALTH_TOKEN` | Aggregated scan reports for a UTC date |
|
||||
| GET | `/hosts` | `HEALTH_TOKEN` | Latest snapshot of every allowlisted workstation |
|
||||
| POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot |
|
||||
|
||||
Routing resolves before authentication: an unknown path or a wrong method
|
||||
answers `404`, never `401`.
|
||||
|
||||
### `POST /hosts/<id>`
|
||||
|
||||
Body: the `GET /health` payload minus `logto` and `timestamp` — `hostname`,
|
||||
`uptime`, `cpu{model,cores,loadAvg,usagePercent}`,
|
||||
`memory{totalGB,usedGB,freeGB,usagePercent}`, `disk{…same four…}`.
|
||||
|
||||
`<id>` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in
|
||||
`HOSTS_ALLOWED_IDS`. That regex is the path-traversal control — anything that
|
||||
fails it never reaches a handler.
|
||||
|
||||
| Code | Case |
|
||||
|------|------|
|
||||
| 204 | Snapshot accepted and written |
|
||||
| 400 | Unparsable JSON, missing field, or wrong type |
|
||||
| 401 | Ingest token missing or wrong |
|
||||
| 403 | `<id>` well-formed but not in the allowlist |
|
||||
| 404 | `<id>` outside the format (never reaches the handler) |
|
||||
| 413 | Body past 4 KiB — connection cut |
|
||||
| 503 | `HOSTS_INGEST_TOKEN` not configured (fail-closed) |
|
||||
|
||||
The stored object is rebuilt field by field from a whitelist (numbers must be
|
||||
finite, strings are capped at 128 chars, everything else dropped) and written
|
||||
atomically via a temp file + `rename`. `receivedAt` is stamped by the server on
|
||||
arrival; a client-supplied timestamp is ignored.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" \
|
||||
-H "Content-Type: application/json" --data @snapshot.json \
|
||||
"https://health.lacompagniemaximus.com/hosts/thinkpad"
|
||||
```
|
||||
|
||||
### `GET /hosts`
|
||||
|
||||
```
|
||||
{ "staleAfterSeconds": 900,
|
||||
"hosts": [ { "id", "hostname", "uptime", "cpu", "memory", "disk",
|
||||
"receivedAt", "ageSeconds", "online" } ] }
|
||||
```
|
||||
|
||||
One entry per allowlisted id. `online = ageSeconds <= staleAfterSeconds`,
|
||||
computed server-side. A host that never checked in — or whose snapshot file is
|
||||
unreadable — degrades to `{ id, receivedAt: null, ageSeconds: null, online:
|
||||
false, neverSeen: true }` instead of failing the response, so the dashboard can
|
||||
say "agent not installed" during commissioning.
|
||||
|
||||
### `GET /defenseurs/findings`
|
||||
|
||||
|
|
@ -45,17 +99,28 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \
|
|||
| Env var | Default | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `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 |
|
||||
| `DEFENSEURS_AGENTS_MAP_PATH` | `/data/defenseurs/agents-map.json` | Project -> agent snapshot |
|
||||
| `LOGTO_HEALTH_URL` | auth.lacompagniemaximus.com | Logto OIDC discovery URL |
|
||||
| `HOSTS_DIR` | `/data/hosts` | Where workstation snapshots are **written** |
|
||||
| `HOSTS_ALLOWED_IDS` | `thinkpad` | Comma-separated allowlist of host ids |
|
||||
| `HOSTS_INGEST_TOKEN` | — | Bearer token for `POST /hosts/<id>` (503 if missing) |
|
||||
| `HOSTS_STALE_SECONDS` | `900` | Age past which a host is reported offline |
|
||||
|
||||
## Bind-mounts (Coolify, read-only)
|
||||
## Bind-mounts (Coolify)
|
||||
|
||||
Read-only for the API — written by the Defenseurs Sergent:
|
||||
|
||||
- `/home/defenseur/defenseurs/status.json` -> `/data/defenseurs/status.json`
|
||||
- `/home/defenseur/defenseurs/reports/` -> `/data/defenseurs/reports/`
|
||||
- `/home/defenseur/defenseurs/agents-map.json` -> `/data/defenseurs/agents-map.json`
|
||||
|
||||
Read-write — the API is the writer:
|
||||
|
||||
- `<host>/data/hosts/` -> `/data/hosts/` (`HOSTS_DIR`). Must be writable by uid
|
||||
1000, the `node` user the container runs as, otherwise every ingest 500s.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
|
|
|
|||
277
__tests__/hosts.test.js
Normal file
277
__tests__/hosts.test.js
Normal 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
647
index.js
|
|
@ -1,7 +1,16 @@
|
|||
const http = require("node:http");
|
||||
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 { createHash, timingSafeEqual } = require("node:crypto");
|
||||
const { collectMetrics } = require("./metrics.js");
|
||||
|
||||
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_CATEGORIES = ["deps", "secrets", "code", "acces", "infra"];
|
||||
|
||||
// --- Workstation snapshots (POST /hosts/<id>, GET /hosts) -------------------
|
||||
|
||||
const HOSTS_DIR = process.env.HOSTS_DIR || "/data/hosts";
|
||||
const HOSTS_INGEST_TOKEN = process.env.HOSTS_INGEST_TOKEN;
|
||||
const MAX_BODY_BYTES = 4096;
|
||||
const MAX_STRING_LENGTH = 128;
|
||||
|
||||
// Single source of truth for what a host id may look like. The allowlist check
|
||||
// and the route matcher are both built from it, so they can never drift apart.
|
||||
//
|
||||
// This narrow pattern IS the path-traversal control. Verified in Node: URL()
|
||||
// normalises `/hosts/../../etc/passwd` to `/etc/passwd`, while `..%2f..%2f` and
|
||||
// `THINKPAD` simply fail the match — every hostile id therefore lands on 404,
|
||||
// before any handler runs. 403 is reserved for well-formed ids that are not in
|
||||
// the allowlist. Never widen this to `^/hosts/(.+)$` to make a test pass.
|
||||
const HOST_ID_PATTERN = "[a-z0-9][a-z0-9-]{0,31}";
|
||||
const HOST_ID_RE = new RegExp(`^${HOST_ID_PATTERN}$`);
|
||||
const HOST_ROUTE_RE = new RegExp(`^/hosts/(${HOST_ID_PATTERN})$`);
|
||||
|
||||
const staleSecondsRaw = parseInt(process.env.HOSTS_STALE_SECONDS || "", 10);
|
||||
const HOSTS_STALE_SECONDS =
|
||||
Number.isFinite(staleSecondsRaw) && staleSecondsRaw > 0 ? staleSecondsRaw : 900;
|
||||
|
||||
// Parse HOSTS_ALLOWED_IDS once at startup and drop anything that is not a valid
|
||||
// host id. Entries feed path.join(HOSTS_DIR, `${id}.json`), so a stray space or
|
||||
// an entry like `../defenseurs/status` must never survive into a file path.
|
||||
// Rejections are logged rather than silently swallowed: a typo in the env var
|
||||
// would otherwise look exactly like a workstation that never checked in.
|
||||
function parseAllowedIds(raw) {
|
||||
const allowed = new Set();
|
||||
for (const entry of String(raw).split(",")) {
|
||||
const id = entry.trim();
|
||||
if (!id) continue;
|
||||
if (!HOST_ID_RE.test(id)) {
|
||||
console.warn(
|
||||
`WARNING: HOSTS_ALLOWED_IDS entry rejected (not a valid host id): ${safeLogValue(id)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
allowed.add(id);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
const HOSTS_ALLOWED_IDS = parseAllowedIds(process.env.HOSTS_ALLOWED_IDS || "thinkpad");
|
||||
|
||||
// Severity filter. Asymmetric rule (issue #3):
|
||||
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
|
||||
// - threshold "INFO" -> INFO only (explicit opt-in)
|
||||
|
|
@ -37,6 +92,10 @@ if (!TOKEN) {
|
|||
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() {
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), LOGTO_TIMEOUT_MS);
|
||||
|
|
@ -178,130 +237,215 @@ async function getHealth() {
|
|||
};
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
// --- Hosts helpers ----------------------------------------------------------
|
||||
|
||||
// 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;
|
||||
// Printable-ASCII-only, length-capped rendering for anything attacker-supplied
|
||||
// that reaches a log line (X-Real-IP, host id, env entries). Keeps a crafted
|
||||
// value from forging extra log records.
|
||||
function safeLogValue(value, max = 64) {
|
||||
return String(value).replace(/[^\x20-\x7e]/g, "?").slice(0, max);
|
||||
}
|
||||
|
||||
const validRoutes = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans"];
|
||||
if (req.method !== "GET" || !validRoutes.includes(pathname)) {
|
||||
res.writeHead(404);
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
return;
|
||||
}
|
||||
// Log every 401/403. This is the first publicly writable surface on the API:
|
||||
// without a trace, a token-guessing campaign would leave nothing behind.
|
||||
function logRejection(req, status, reason, id) {
|
||||
const ip = safeLogValue(req.headers["x-real-ip"] || req.socket?.remoteAddress || "unknown");
|
||||
const method = safeLogValue(req.method, 8);
|
||||
const url = safeLogValue(req.url, 120);
|
||||
console.warn(
|
||||
`[auth] ${status} ${method} ${url} ip=${ip} id=${id ? safeLogValue(id, 32) : "-"} reason=${reason}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!TOKEN) {
|
||||
res.writeHead(401);
|
||||
res.end(JSON.stringify({ error: "HEALTH_TOKEN not configured" }));
|
||||
return;
|
||||
}
|
||||
// Constant-time bearer comparison for the ingestion path. Both sides are hashed
|
||||
// first, so the buffers are always 32 bytes: timingSafeEqual never throws on
|
||||
// mismatched lengths, and the length of the real token cannot leak.
|
||||
//
|
||||
// Deliberately scoped to the new write path. Realigning the existing
|
||||
// HEALTH_TOKEN comparison touches every read route in production and is tracked
|
||||
// separately (issue #17).
|
||||
function tokenMatches(provided, expected) {
|
||||
if (typeof provided !== "string" || typeof expected !== "string") return false;
|
||||
const a = createHash("sha256").update(provided).digest();
|
||||
const b = createHash("sha256").update(expected).digest();
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
const auth = req.headers["authorization"];
|
||||
if (auth !== `Bearer ${TOKEN}`) {
|
||||
res.writeHead(401);
|
||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
return;
|
||||
}
|
||||
// Read the request body with a hard 4 KiB ceiling. The counter runs on the
|
||||
// bytes actually received, never on Content-Length: a client can lie in the
|
||||
// header, and chunked transfer-encoding omits it altogether.
|
||||
//
|
||||
// Resolves to the raw string, or to null when the response has already been
|
||||
// written (413) or the socket died — in which case the caller must not answer.
|
||||
function readBody(req, res) {
|
||||
return new Promise((resolve) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
let settled = false;
|
||||
|
||||
if (pathname === "/defenseurs") {
|
||||
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" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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" }));
|
||||
req.on("data", (chunk) => {
|
||||
if (settled) return;
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) {
|
||||
settled = true;
|
||||
req.pause();
|
||||
res.writeHead(413);
|
||||
// Destroy only once the 413 has been flushed: req.destroy() tears down
|
||||
// the socket, so cutting first would leave the client with a network
|
||||
// error instead of a status code.
|
||||
res.end(JSON.stringify({ error: "Payload too large" }), () => req.destroy());
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
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 }));
|
||||
}
|
||||
return;
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(Buffer.concat(chunks).toString("utf-8"));
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 date = 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;
|
||||
}
|
||||
const hostname = cleanString(payload.hostname);
|
||||
if (hostname === null) return { error: "hostname must be a string" };
|
||||
|
||||
const uptime = cleanNumber(payload.uptime);
|
||||
if (uptime === null) return { error: "uptime must be a finite number" };
|
||||
|
||||
const cpu = sanitizeCpu(payload.cpu);
|
||||
if (cpu.error) return cpu;
|
||||
const memory = sanitizeUsage(payload.memory, "memory");
|
||||
if (memory.error) return memory;
|
||||
const disk = sanitizeUsage(payload.disk, "disk");
|
||||
if (disk.error) return disk;
|
||||
|
||||
return {
|
||||
snapshot: {
|
||||
hostname,
|
||||
uptime,
|
||||
cpu: cpu.value,
|
||||
memory: memory.value,
|
||||
disk: disk.value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Atomic write: temp file in the SAME directory, then renameSync. rename is
|
||||
// atomic within a filesystem, so a concurrent GET /hosts either sees the old
|
||||
// snapshot or the new one — never a half-written JSON.
|
||||
function writeHostSnapshot(id, record) {
|
||||
mkdirSync(HOSTS_DIR, { recursive: true });
|
||||
const finalPath = path.join(HOSTS_DIR, `${id}.json`);
|
||||
const tmpPath = path.join(HOSTS_DIR, `.${id}.${process.pid}.${Date.now()}.tmp`);
|
||||
try {
|
||||
writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
|
||||
renameSync(tmpPath, finalPath);
|
||||
} catch (err) {
|
||||
try {
|
||||
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 }));
|
||||
unlinkSync(tmpPath);
|
||||
} catch {
|
||||
// Best effort — the temp file may never have been created.
|
||||
}
|
||||
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 {
|
||||
const data = await getHealth();
|
||||
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) {
|
||||
const server = http.createServer(handler);
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue