vps-health-api/README.md
le king fu e24f5b6f00 feat(agent): push workstation metrics to /hosts/<id> from cron
Zero-dependency local agent: collect one snapshot through the shared
metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five
minutes. Nothing is queued and nothing is replayed — a heartbeat from five
minutes ago describes a machine that no longer exists, so a failed push is
logged and dropped rather than spooled.

run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to
start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never
enables shell tracing: the script is meant to be piped into `logger`, where
`set -x` would echo the ingest token into /var/log/syslog and journald for
good. Same reason the failure path reports only err.code and the HTTP status —
never an error object, request options, headers, or a response body. The cost
is accepted: a 400 says the payload was rejected, not why.

50 tests, including two that spawn the real process against a failing server
and scan its actual stdout and stderr for any five-character fragment of the
token. The payload is pinned against the server's own sanitizeSnapshot(), and
one case pushes a real collectMetrics() snapshot through the real ingestion
handler, so a drift between agent and server turns a test red instead of
producing a 400 at 3 a.m. on the ThinkPad.

agent/ stays out of the Docker image: the COPY line is untouched, and a test
pins that it copies files one by one and never names the directory.

Installing on a workstation — frozen copy, env file, crontab line, dry run,
and why two machines must never share a HOST_ID — is documented in
agent/README.md. The install itself belongs to the commissioning issue.

Resolves #15
2026-08-16 12:21:59 -04:00

136 lines
5.5 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 | `/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/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_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`](agent/README.md).
## Tests
```
npm install
npm test
```