vps-health-api/README.md
le king fu 48c9a408f6
All checks were successful
CI / verification (pull_request) Successful in 22s
CI / verification (push) Successful in 20s
docs: bound the write guard's claims and name the owning group (#36)
The docs promised that writes.test.js pins every write. They now say what
it covers: node:fs writes and shell commands, on the paths it exercises.
They also name what keeps the second layer up: the Defenseurs content is
group-writable, and only the owning group (defenseur, gid 1001, not the
gid 1000 of node and ubuntu) keeps the container out, so a chgrp ubuntu
would drop that layer silently.

Fixes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:59:44 -04:00

182 lines
7.9 KiB
Markdown

# 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:defenseur`, uid and gid 1001,
directories 755/775, files 664, while the container runs as uid 1000 with no
supplementary group). The group bits grant write, so the second layer holds
only while the owning group is not gid 1000 (`node` in the image, `ubuntu` on
the host): a `chgrp ubuntu` would drop it silently.
`__tests__/writes.test.js` pins that `node:fs` writes go nowhere but
`HOSTS_DIR`, and that the only shell commands are the two read-only ones in
`metrics.js`, on every path it exercises: boot, nominal reads and ingest,
rejections, invalid input, a corrupt file, missing files.
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`](agent/README.md).
## Tests
```
npm install
npm test
```