No description
Find a file
le king fu 7523b397f0 test: cover degraded paths and shell commands in the write guard (#36)
Review of PR #39: the spies only watched authenticated requests on
complete fixtures, so a write on an error path passed the whole suite
(reviewer mutations R1, R2, R3 and R11: quarantining a corrupt
agents-map.json, recreating a missing status.json, logging a 401/403 or a
refused payload to disk).

A new case walks rejections (401/403/404), invalid input (400/413), a
corrupt agents-map.json, missing Defenseurs files and a corrupt snapshot,
requires the degraded content (no_data, neverSeen), and requires no write
outside HOSTS_DIR. child_process is now spied the same way as node:fs and
the set of shell commands is pinned to the two read-only ones metrics.js
runs, with GET /health as positive control.

R1, R2, R3 and R11 went from 0 to 1 red each; R4 (touch through
execFileSync) is now caught by this file. A write deferred by a timer
longer than 20 ms (R8b) stays out of sight, as the header says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:59:44 -04:00
.forgejo/workflows ci: add a Forgejo Actions workflow with a boot-from-COPY check (#24) 2026-09-27 20:22:48 -04:00
__tests__ test: cover degraded paths and shell commands in the write guard (#36) 2026-09-30 19:59:44 -04:00
agent fix(hosts): require 3 loadAvg values and sweep orphan temp files (#25) 2026-09-29 20:30:07 -04:00
scripts ci: pin the review follow-ups of the CI workflow (#33) 2026-09-27 20:54:47 -04:00
.env.example docs: record the read-only Defenseurs mounts (#36) 2026-09-29 22:06:39 -04:00
.gitignore feat: initial vps-health-api service 2026-02-26 20:48:09 -05:00
CLAUDE.md docs: record the read-only Defenseurs mounts (#36) 2026-09-29 22:06:39 -04:00
Dockerfile refactor: extract CPU/RAM/disk collection into metrics.js 2026-08-16 11:30:03 -04:00
index.js fix(hosts): require 3 loadAvg values and sweep orphan temp files (#25) 2026-09-29 20:30:07 -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 docs: record the read-only Defenseurs mounts (#36) 2026-09-29 22:06:39 -04:00
test-curl.sh test(defenseurs): cover GET /defenseurs/state in the socket-level pass 2026-09-24 20:42:56 -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 /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…}. loadAvg must hold exactly 3 finite numbers (the 1, 5 and 15-minute averages); any other length is a 400.

<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.

Strings are truncated, not filtered: control characters and markup are kept so a legitimate value is never mangled. Consumers must escape them when rendering (React text nodes do; dangerouslySetInnerHTML or an href would not).

A process killed mid-write can leave a .<id>.<pid>.<ms>.tmp file behind. Startup removes the ones older than an hour; GET /hosts never reads them.

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)

Mounted read-only (:ro) — written on the host by the Defenseurs Sergent:

  • /data/defenseurs -> /data/defenseurs:ro — holds 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:ro — its own mount, nested inside the previous one.

Two layers keep the API out of these files: the :ro mode (a suffix on the Coolify destination path, copied as-is into the generated compose), and Unix permissions (content owned by defenseur, uid 1001, directories 755/775, files 664, while the container runs as uid 1000 with no supplementary group). __tests__/writes.test.js pins that the API writes nowhere but HOSTS_DIR.

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