vps-health-api/README.md
le king fu a5d1bcdedb feat(defenseurs): serve the Sergent's findings state at GET /defenseurs/state
The Sergent now persists per-finding state (firstSeen, unverified, 30-day
history) in findings-state.json next to status.json. The admin dashboard
needs it for the drill-down, since it is the only source carrying a
finding's age.

- New read route GET /defenseurs/state, path from
  DEFENSEURS_FINDINGS_STATE_PATH (default /data/defenseurs/findings-state.json),
  already covered by the /data/defenseurs mount.
- Extract serveJsonFile() so /defenseurs and /defenseurs/state share one
  contract: raw content, or 200 {status:"no_data"} on any read error.
- Tests: new state.test.js (byte-identical body, no_data, path isolation,
  401 x3, 404 on POST) and the route added to the auth.test.js matrix.
- Docs: README and CLAUDE.md endpoint, config and mount sections; the README
  mount list now matches the real /data/defenseurs directory mount.

Fixes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:42:20 -04:00

6.6 KiB

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 /defenseurs/state HEALTH_TOKEN Sergent's per-finding state (findings-state.json)
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/state

Serves the Sergent's findings-state.json verbatim — per-finding firstSeen, unverified status and 30-day history. It is the only source that carries a finding's age, which is why the admin dashboard's drill-down reads it alongside /defenseurs. The file is not parsed or checked for freshness here: the Sergent writes it atomically and quarantines unreadable state itself, and staleness is judged by the reader.

  • 200 — raw file content
  • 200 { status: "no_data" } — file absent or unreadable (same contract as /defenseurs)
  • 401 — missing or invalid token
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
  "https://health.lacompagniemaximus.com/defenseurs/state"

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
  • 500 — agents-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_STATUS_PATH /data/defenseurs/status.json Served by /defenseurs
DEFENSEURS_FINDINGS_STATE_PATH /data/defenseurs/findings-state.json Served by /defenseurs/state
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:

  • /data/defenseurs/ -> /data/defenseurs/ — directory mount holding status.json, findings-state.json and agents-map.json, all written there directly by the Sergent. (/home/defenseur/defenseurs/status.json is an obsolete decoy nobody reads.)
  • /home/defenseur/defenseurs/reports/ -> /data/defenseurs/reports/

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