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>
159 lines
6.6 KiB
Markdown
159 lines
6.6 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…}`.
|
|
|
|
`<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`](agent/README.md).
|
|
|
|
## Tests
|
|
|
|
```
|
|
npm install
|
|
npm test
|
|
```
|