No description
Find a file
le king fu e181a9691c fix(review): close the three gaps found in stack review
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.
2026-08-16 14:24:43 -04:00
__tests__ fix(review): close the three gaps found in stack review 2026-08-16 14:24:43 -04:00
agent fix(review): close the three gaps found in stack review 2026-08-16 14:24:43 -04:00
.env.example feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts) 2026-08-16 11:49:36 -04:00
.gitignore feat: initial vps-health-api service 2026-02-26 20:48:09 -05:00
CLAUDE.md feat(agent): push workstation metrics to /hosts/<id> from cron 2026-08-16 12:21:59 -04:00
Dockerfile refactor: extract CPU/RAM/disk collection into metrics.js 2026-08-16 11:30:03 -04:00
index.js feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts) 2026-08-16 11:49:36 -04:00
metrics.js refactor: extract CPU/RAM/disk collection into metrics.js 2026-08-16 11:30:03 -04:00
package-lock.json feat(defenseurs): add GET /defenseurs/findings?project=X route 2026-05-12 20:56:56 -04:00
package.json feat(defenseurs): add GET /defenseurs/findings?project=X route 2026-05-12 20:56:56 -04:00
README.md feat(agent): push workstation metrics to /hosts/<id> from cron 2026-08-16 12:21:59 -04:00
test-curl.sh feat(reports): scan archive/ subdir as fallback to handle post-07:30 UTC window 2026-05-10 16:53:14 -04:00
vitest.config.js feat(defenseurs): add GET /defenseurs/findings?project=X route 2026-05-12 20:56:56 -04:00

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 timestamphostname, uptime, cpu{model,cores,loadAvg,usagePercent}, memory{totalGB,usedGB,freeGB,usagePercent}, disk{…same four…}.

<id> must match ^[a-z0-9][a-z0-9-]{0,31}$ and be listed in HOSTS_ALLOWED_IDS. That regex is the path-traversal control — anything that fails it never reaches a handler.

Code Case
204 Snapshot accepted and written
400 Unparsable JSON, missing field, or wrong type
401 Ingest token missing or wrong
403 <id> well-formed but not in the allowlist
404 <id> outside the format (never reaches the handler)
413 Body past 4 KiB — connection cut
503 HOSTS_INGEST_TOKEN not configured (fail-closed)

The stored object is rebuilt field by field from a whitelist (numbers must be finite, strings are capped at 128 chars, everything else dropped) and written atomically via a temp file + rename. receivedAt is stamped by the server on arrival; a client-supplied timestamp is ignored.

Example:

curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" \
  -H "Content-Type: application/json" --data @snapshot.json \
  "https://health.lacompagniemaximus.com/hosts/thinkpad"

GET /hosts

{ "staleAfterSeconds": 900,
  "hosts": [ { "id", "hostname", "uptime", "cpu", "memory", "disk",
               "receivedAt", "ageSeconds", "online" } ] }

One entry per allowlisted id. online = ageSeconds <= staleAfterSeconds, computed server-side. A host that never checked in — or whose snapshot file is unreadable — degrades to { id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true } instead of failing the response, so the dashboard can say "agent not installed" during commissioning.

GET /defenseurs/findings

Query params:

  • project (required) — project name, looked up in agents-map.json (e.g. la-suite-booking)
  • category (optional) — exact match, one of deps|secrets|code|acces|infra
  • severity (optional) — threshold, one of CRITICAL|HIGH|MEDIUM|LOW|INFO
    • default (no param): MEDIUM, HIGH, CRITICAL
    • LOW returns LOW+MEDIUM+HIGH+CRITICAL but still hides INFO
    • INFO returns INFO only (explicit opt-in)

Responses:

  • 200 { agent, project, timestamp, findings: Finding[] } — report present (empty findings if clean scan; no status field)
  • 200 { findings: [], status: "no_data" } — no report on file for the agent
  • 400 — missing project or invalid category / severity
  • 401 — missing or invalid token
  • 404 — unknown project
  • 500agents-map.json unreadable or corrupted

Example:

curl -H "Authorization: Bearer $HEALTH_TOKEN" \
  "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"

Config

Env var Default Purpose
PORT 3001 HTTP port
HEALTH_TOKEN Bearer token for the read routes (fail-closed if missing)
REPORTS_DIR /data/defenseurs/reports Scan reports dir
DEFENSEURS_AGENTS_MAP_PATH /data/defenseurs/agents-map.json Project -> agent snapshot
LOGTO_HEALTH_URL auth.lacompagniemaximus.com Logto OIDC discovery URL
HOSTS_DIR /data/hosts Where workstation snapshots are written
HOSTS_ALLOWED_IDS thinkpad Comma-separated allowlist of host ids
HOSTS_INGEST_TOKEN Bearer token for POST /hosts/<id> (503 if missing)
HOSTS_STALE_SECONDS 900 Age past which a host is reported offline

Bind-mounts (Coolify)

Read-only for the API — written by the Defenseurs Sergent:

  • /home/defenseur/defenseurs/status.json -> /data/defenseurs/status.json
  • /home/defenseur/defenseurs/reports/ -> /data/defenseurs/reports/
  • /home/defenseur/defenseurs/agents-map.json -> /data/defenseurs/agents-map.json

Read-write — the API is the writer:

  • <host>/data/hosts/ -> /data/hosts/ (HOSTS_DIR). Must be writable by uid 1000, the node user the container runs as, otherwise every ingest 500s.

Workstation agent

agent/ holds the zero-dependency collector that runs from cron on a workstation and feeds POST /hosts/<id>. It is not part of the server image — it is installed by copying files onto the machine it measures. Install steps, crontab line and failure modes: agent/README.md.

Tests

npm install
npm test