No description
Find a file
le king fu 156ca818ca
All checks were successful
CI / verification (pull_request) Successful in 19s
CI / verification (push) Successful in 20s
ci: pin the review follow-ups of the CI workflow (#33)
- Forbid `continue-on-error` in ci.yml: that mutation left every test green,
  and a failing step would turn the commit status green in silence.
- boot-from-copy.sh refuses a port already held by something else (any curl
  exit other than 7). Without it, the wait loop could reach the neighbour and
  assert on a server it never started — the new test reproduces exactly that
  when the check is removed.
- Pin every require of the COPY'd runtime modules at column 0, with no
  dynamic import: the boot check only calls /health, so it only proves the
  modules loaded at startup.

Fixes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:54:47 -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__ ci: pin the review follow-ups of the CI workflow (#33) 2026-09-27 20:54:47 -04:00
agent fix(review): close the three gaps found in stack review 2026-08-16 14:24:43 -04:00
scripts ci: pin the review follow-ups of the CI workflow (#33) 2026-09-27 20:54:47 -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 ci: pin the review follow-ups of the CI workflow (#33) 2026-09-27 20:54:47 -04:00
Dockerfile refactor: extract CPU/RAM/disk collection into metrics.js 2026-08-16 11:30:03 -04:00
index.js fix(auth): compare HEALTH_TOKEN in constant time via tokenMatches 2026-09-27 16:06:04 -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(defenseurs): serve the Sergent's findings state at GET /defenseurs/state 2026-09-24 20:42:20 -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…}.

<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