No description
Find a file
le king fu 50a829beb3 test(hosts): extend the socket-level pass to the two workstation routes
test-curl.sh predates vitest and still claimed to be the authoritative suite
for one endpoint. It is now the socket-level pass, and its header says so:
vitest covers the logic, this script covers what only a real client and a
real TCP connection can show.

Twenty-four cases for POST /hosts/<id> and GET /hosts, chosen for what they
prove rather than for coverage: the 413 answered before the connection is
cut, the two tokens refusing each other's routes in both directions, 401
landing ahead of 403 so an unauthenticated caller cannot probe the allowlist,
the route regex turning a malformed id and a traversal attempt into 404
before any handler runs, and the ingest-persist-read round trip closing on a
GET that shows the pushed hostname online.

Two cases needed care to be worth anything:

The hostile payload splices "__proto__" into the JSON as text. Written as
__proto__: in an object literal it would set the prototype and JSON.stringify
would emit nothing, leaving the case asserting against a payload that never
carried the key. It now checks the response and the persisted file, whose key
set must be exactly the whitelist.

The 413 case runs under set -e while the server destroys the socket right
after flushing the response, so curl can exit 55/56 having already read the
status line. Every probe goes through an http_code helper that absorbs that;
a curl which truly got nothing reports 000, which fails the case rather than
passing it quietly. -H "Expect:" also suppresses the 100-continue handshake,
which otherwise changes which side notices the reset first.

The fail-closed 503 needs a server booted without HOSTS_INGEST_TOKEN, so the
script now runs a second instance on 3098 for it, and confirms reads still
answer 200 there — the two tokens are independent, including in absence.

The EXIT trap dereferenced an unset SERVER_PID under set -u, which made it
error out instead of cleaning up when anything failed before the boot. Both
PIDs are initialised and the kills guarded.

CLAUDE.md: the vitest count was stale (244 -> 251, auth.test.js 19 -> 26) and
test-curl.sh was undocumented.

Refs #16

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:18:53 -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 test(hosts): extend the socket-level pass to the two workstation routes 2026-08-18 20:18:53 -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 test(hosts): extend the socket-level pass to the two workstation routes 2026-08-18 20:18:53 -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