Follow-up to the /pr-review pass on PRs #18-#22. Three findings, none of which changed behaviour, all of which weakened a guarantee the stack was supposed to provide. 1. auth.test.js carried "any new route must be added here" but /hosts was never added when #13 introduced it, so no test asserted GET /hosts -> 401 on a missing header. The drift class the file exists to catch slipped on its first outing. ROUTES now covers /hosts, and a dedicated block pins both directions of the token separation: a read token cannot write, an ingest token cannot read. 2. ingestOversized() folded any transport error into 413, so the four oversized-body tests would have stayed green if the server had stopped writing the status and merely killed the socket - on the one path where "a status, not a dead socket" is the whole client contract. Transport errors are now surfaced instead of swallowed. 3. HOST_AGENT_TIMEOUT_MS was read by push-metrics.js but never exported by run-push.sh, so setting it in the documented env file did nothing. Now exported and documented. Also drops a claim from agent/README.md that the review proved false: the ingest/read token split buys no containment on the ThinkPad, which already stores HEALTH_TOKEN in cleartext for defenseur-auto. Losing that laptop compromises both, so they rotate together. |
||
|---|---|---|
| __tests__ | ||
| agent | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| Dockerfile | ||
| index.js | ||
| metrics.js | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| test-curl.sh | ||
| vitest.config.js | ||
vps-health-api
Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtime deps.
Endpoints
Read endpoints require Authorization: Bearer $HEALTH_TOKEN. The single write
endpoint uses its own token, $HOSTS_INGEST_TOKEN, so a workstation agent can
push its snapshot without gaining read access to the Defenseurs reports.
| Method | Path | Token | Description |
|---|---|---|---|
| GET | /health |
HEALTH_TOKEN |
CPU, memory, disk, uptime, Logto reachability |
| GET | /defenseurs |
HEALTH_TOKEN |
Defenseurs executive status (status.json) |
| GET | /defenseurs/findings?project=X |
HEALTH_TOKEN |
Detailed findings for a project's Defenseur |
| GET | /reports/scans?date=YYYY-MM-DD |
HEALTH_TOKEN |
Aggregated scan reports for a UTC date |
| GET | /hosts |
HEALTH_TOKEN |
Latest snapshot of every allowlisted workstation |
| POST | /hosts/<id> |
HOSTS_INGEST_TOKEN |
Ingest one workstation snapshot |
Routing resolves before authentication: an unknown path or a wrong method
answers 404, never 401.
POST /hosts/<id>
Body: the GET /health payload minus logto and timestamp — hostname,
uptime, cpu{model,cores,loadAvg,usagePercent},
memory{totalGB,usedGB,freeGB,usagePercent}, disk{…same four…}.
<id> must match ^[a-z0-9][a-z0-9-]{0,31}$ and be listed in
HOSTS_ALLOWED_IDS. That regex is the path-traversal control — anything that
fails it never reaches a handler.
| Code | Case |
|---|---|
| 204 | Snapshot accepted and written |
| 400 | Unparsable JSON, missing field, or wrong type |
| 401 | Ingest token missing or wrong |
| 403 | <id> well-formed but not in the allowlist |
| 404 | <id> outside the format (never reaches the handler) |
| 413 | Body past 4 KiB — connection cut |
| 503 | HOSTS_INGEST_TOKEN not configured (fail-closed) |
The stored object is rebuilt field by field from a whitelist (numbers must be
finite, strings are capped at 128 chars, everything else dropped) and written
atomically via a temp file + rename. receivedAt is stamped by the server on
arrival; a client-supplied timestamp is ignored.
Example:
curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" \
-H "Content-Type: application/json" --data @snapshot.json \
"https://health.lacompagniemaximus.com/hosts/thinkpad"
GET /hosts
{ "staleAfterSeconds": 900,
"hosts": [ { "id", "hostname", "uptime", "cpu", "memory", "disk",
"receivedAt", "ageSeconds", "online" } ] }
One entry per allowlisted id. online = ageSeconds <= staleAfterSeconds,
computed server-side. A host that never checked in — or whose snapshot file is
unreadable — degrades to { id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true } instead of failing the response, so the dashboard can
say "agent not installed" during commissioning.
GET /defenseurs/findings
Query params:
project(required) — project name, looked up inagents-map.json(e.g.la-suite-booking)category(optional) — exact match, one ofdeps|secrets|code|acces|infraseverity(optional) — threshold, one ofCRITICAL|HIGH|MEDIUM|LOW|INFO- default (no param):
MEDIUM,HIGH,CRITICAL LOWreturnsLOW+MEDIUM+HIGH+CRITICALbut still hidesINFOINFOreturnsINFOonly (explicit opt-in)
- default (no param):
Responses:
200 { agent, project, timestamp, findings: Finding[] }— report present (emptyfindingsif clean scan; nostatusfield)200 { findings: [], status: "no_data" }— no report on file for the agent400— missingprojector invalidcategory/severity401— missing or invalid token404— unknown project500—agents-map.jsonunreadable or corrupted
Example:
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
"https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"
Config
| Env var | Default | Purpose |
|---|---|---|
PORT |
3001 |
HTTP port |
HEALTH_TOKEN |
— | Bearer token for the read routes (fail-closed if missing) |
REPORTS_DIR |
/data/defenseurs/reports |
Scan reports dir |
DEFENSEURS_AGENTS_MAP_PATH |
/data/defenseurs/agents-map.json |
Project -> agent snapshot |
LOGTO_HEALTH_URL |
auth.lacompagniemaximus.com | Logto OIDC discovery URL |
HOSTS_DIR |
/data/hosts |
Where workstation snapshots are written |
HOSTS_ALLOWED_IDS |
thinkpad |
Comma-separated allowlist of host ids |
HOSTS_INGEST_TOKEN |
— | Bearer token for POST /hosts/<id> (503 if missing) |
HOSTS_STALE_SECONDS |
900 |
Age past which a host is reported offline |
Bind-mounts (Coolify)
Read-only for the API — written by the Defenseurs Sergent:
/home/defenseur/defenseurs/status.json->/data/defenseurs/status.json/home/defenseur/defenseurs/reports/->/data/defenseurs/reports//home/defenseur/defenseurs/agents-map.json->/data/defenseurs/agents-map.json
Read-write — the API is the writer:
<host>/data/hosts/->/data/hosts/(HOSTS_DIR). Must be writable by uid 1000, thenodeuser the container runs as, otherwise every ingest 500s.
Workstation agent
agent/ holds the zero-dependency collector that runs from cron on a
workstation and feeds POST /hosts/<id>. It is not part of the server image —
it is installed by copying files onto the machine it measures. Install steps,
crontab line and failure modes: agent/README.md.
Tests
npm install
npm test