All checks were successful
CI / verification (pull_request) Successful in 36s
Track the PostgreSQL cluster backups (la-compagnie-maximus#220) the same
way as /claude-usage: freshness is decided by the server.
- GET /backups (read token) serves { vps, lenovo }: the status.json the
VPS script writes, rebuilt through a whitelist (closed error codes,
rotation block included), and the last workstation pull report. Each
block gets ageSeconds, stale and staleAfterSeconds from the server
(BACKUPS_STALE_SECONDS, 36 h): VPS on generatedAt, workstation on the
server-stamped receivedAt. A broken file gives a null block plus
vpsError/lenovoError, never a 500; no_data only when both are absent.
- POST /backups/pull: fourth token kind, backups-ingest, on
BACKUPS_INGEST_TOKEN (503 naming it when unset). Strict validation of
{ pulledAt, files, ok, error? }, server receivedAt, atomic write in
mode 600 through writeJsonAtomic(), now shared with /claude-usage.
Answers 204 and 413 like the two other ingest routes (the agent only
checks for a 2xx), where the issue said 200 and 400.
- backups.js joins the Dockerfile COPY line. Tests: backups.test.js
(101 cases), auth/writes pins extended, 6 socket cases in test-curl.sh.
Refs #58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
325 lines
16 KiB
Markdown
325 lines
16 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`. Each write
|
||
endpoint has its own token — `$HOSTS_INGEST_TOKEN` for workstation snapshots,
|
||
`$CLAUDE_USAGE_INGEST_TOKEN` for the Claude usage snapshot,
|
||
`$BACKUPS_INGEST_TOKEN` for the backup pull report — so an agent can push its
|
||
data without gaining read access to the Defenseurs reports, and no ingestion
|
||
token opens another one's route.
|
||
|
||
| 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 |
|
||
| GET | `/claude-usage` | `HEALTH_TOKEN` | Latest Claude usage snapshot, with server-side staleness |
|
||
| POST | `/claude-usage` | `CLAUDE_USAGE_INGEST_TOKEN` | Ingest the Claude usage snapshot |
|
||
| GET | `/backups` | `HEALTH_TOKEN` | PostgreSQL backups: VPS status and last workstation pull, with server-side staleness |
|
||
| POST | `/backups/pull` | `BACKUPS_INGEST_TOKEN` | Ingest the workstation's pull report |
|
||
|
||
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.
|
||
|
||
### `POST /claude-usage`
|
||
|
||
Body: the Claude usage snapshot, contract `schemaVersion: 1`, shared with the
|
||
workstation agent and the admin dashboard:
|
||
|
||
```jsonc
|
||
{
|
||
"schemaVersion": 1,
|
||
"generatedAt": "2026-09-27T19:00:00.000Z", // workstation clock, informative
|
||
"claudeCodeVersion": "2.1.283",
|
||
"timeZone": "America/Toronto",
|
||
"limits": {
|
||
"status": "ok", // ok | token_expired | http_error | unavailable | disabled
|
||
"detail": "http_429", // optional reason code
|
||
"fetchedAt": "2026-09-27T19:00:00.000Z", // or null
|
||
"items": [ { "kind": "session", "group": "session", "percent": 1,
|
||
"severity": "normal", "resetsAt": "2026-09-27T21:30:00Z", "model": null } ]
|
||
},
|
||
"days": [ { "date": "2026-09-27",
|
||
"models": { "claude-opus-5-5": { "input": 2, "output": 1950, "cacheCreation": 43176,
|
||
"cacheRead": 33814, "requests": 1 } } } ],
|
||
"projects": [ { "name": "simpl-resultat", "work": 123456, "cacheRead": 7890123, "requests": 812 } ]
|
||
}
|
||
```
|
||
|
||
The server rebuilds it from a whitelist (`sanitizeClaudeUsage` in
|
||
`claude-usage.js`, a pure module the agent can test against). An unknown field
|
||
is dropped; an invalid value is dropped (the key is left out), never coerced:
|
||
|
||
- numbers must be safe integers ≥ 0 (`Number.isSafeInteger`); `percent` 0–100;
|
||
- `limits.status` must be in the enum; `limits.detail` must match
|
||
`^[a-z0-9_]{1,32}$` (a reason code, never free text);
|
||
- `days` ≤ 31 (past that, the most recent 31), `date` `^\d{4}-\d{2}-\d{2}$`
|
||
and a real calendar day; `models` ≤ 12 keys matching
|
||
`^[a-z0-9][a-z0-9.\-]{0,63}$`; `limits.items` ≤ 12; `projects` ≤ 9;
|
||
- timestamps must be full ISO 8601 with an offset; other strings are
|
||
truncated to 128 characters.
|
||
|
||
| Code | Case |
|
||
|------|------|
|
||
| 204 | Snapshot accepted and written |
|
||
| 400 | Unparsable JSON, a body that is not an object, or `schemaVersion` other than 1 |
|
||
| 401 | Token missing or wrong — including `HOSTS_INGEST_TOKEN` or `HEALTH_TOKEN` |
|
||
| 413 | Body past 64 KiB (65 536 bytes received) — connection cut |
|
||
| 503 | `CLAUDE_USAGE_INGEST_TOKEN` not configured (fail-closed) |
|
||
|
||
`receivedAt` is stamped by the server; the file is written atomically (temp
|
||
file with the pid in its name, mode 600, then `rename`).
|
||
|
||
```
|
||
curl -X POST -H "Authorization: Bearer $CLAUDE_USAGE_INGEST_TOKEN" \
|
||
-H "Content-Type: application/json" --data @claude-usage.json \
|
||
"https://health.lacompagniemaximus.com/claude-usage"
|
||
```
|
||
|
||
### `GET /claude-usage`
|
||
|
||
```
|
||
{ "staleAfterSeconds": 2700, "receivedAt": "…", "ageSeconds": 42,
|
||
"stale": false, "snapshot": { …sanitized snapshot… } }
|
||
```
|
||
|
||
The stored file is re-run through the sanitizer on the way out.
|
||
`stale = ageSeconds > staleAfterSeconds`, decided by the server only — the
|
||
dashboard displays it, it does not recompute it. A missing or unreadable file
|
||
(corrupt, off-contract, no usable `receivedAt`) answers
|
||
`200 { "status": "no_data" }`.
|
||
|
||
### `POST /backups/pull`
|
||
|
||
Sent once a day by the workstation pull agent
|
||
([`agent/pg-backup-pull/`](agent/README.md)) after it fetched the encrypted
|
||
dumps (la-compagnie-maximus#220). Body, validated strictly (`backups.js`):
|
||
|
||
```
|
||
{ "pulledAt": "<ISO 8601 with offset>", "files": <integer >= 0>, "ok": true|false, "error"?: "pull_failed"|"rotation_failed" }
|
||
```
|
||
|
||
`error` is required when `ok` is `false` and refused when it is `true`; any
|
||
other code is a 400. Unknown fields, a client `receivedAt` among them, are
|
||
dropped: the server stamps `receivedAt` itself. The report is written
|
||
atomically (temp file in the same directory, mode 600, `rename`).
|
||
|
||
| Code | Meaning |
|
||
|------|---------|
|
||
| 204 | Accepted |
|
||
| 400 | Not JSON, not an object, a field missing or invalid, an error code outside the list |
|
||
| 401 | Missing or wrong token (the three other tokens included) |
|
||
| 413 | Body over 1 KiB (bytes received, not `Content-Length`); same answer as the other ingest routes |
|
||
| 503 | `BACKUPS_INGEST_TOKEN` not configured (fail-closed) |
|
||
|
||
### `GET /backups`
|
||
|
||
```
|
||
{ "vps": { …status.json, whitelisted…, "ageSeconds": 31000, "stale": false, "staleAfterSeconds": 129600 } | null,
|
||
"lenovo": { "pulledAt": "…", "files": 9, "ok": true, "receivedAt": "…", "ageSeconds": 28000, "stale": false, "staleAfterSeconds": 129600 } | null,
|
||
"vpsError"?: "<code>", "lenovoError"?: "<code>" }
|
||
```
|
||
|
||
- `vps` is the `status.json` written by the VPS script (`backup-postgres.sh`,
|
||
contract `schemaVersion: 1`: `generatedAt`, `ok`, `expectedCount`,
|
||
`databases[]`, `globals`, `mirror`, `rotation`, `retentionDays`), rebuilt
|
||
through a whitelist: an unknown field is dropped, an invalid value is dropped
|
||
rather than coerced, an `error` outside the closed list of codes is dropped
|
||
(the entry keeps `ok: false`), a database name the script refused is dropped.
|
||
Its age runs from `generatedAt`.
|
||
- `lenovo` is the last `POST /backups/pull`, aged on the server's `receivedAt`,
|
||
never on the workstation's `pulledAt`.
|
||
- `stale = ageSeconds > staleAfterSeconds` (`BACKUPS_STALE_SECONDS`, 36 h),
|
||
decided by the server only: the admin card and `/warmup` display it.
|
||
- A file that is there but unusable gives a `null` block plus `vpsError` or
|
||
`lenovoError` (`unreadable`, `invalid_json`, `unsupported_schema`,
|
||
`invalid_contract`), never a 500, and the other block is still served.
|
||
`200 { "status": "no_data" }` only when both files are absent.
|
||
|
||
### `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 |
|
||
| `CLAUDE_USAGE_INGEST_TOKEN` | — | Bearer token for `POST /claude-usage` (503 if missing) |
|
||
| `CLAUDE_USAGE_PATH` | `/data/claude-usage/claude-usage.json` | Where the Claude usage snapshot is **written** |
|
||
| `CLAUDE_USAGE_STALE_SECONDS` | `2700` | Age past which `GET /claude-usage` reports `stale: true` |
|
||
| `BACKUPS_STATUS_PATH` | `/data/backups-status/status.json` | VPS backup status, **read** by `GET /backups` |
|
||
| `BACKUPS_PULL_PATH` | `/data/backups-pull/pull.json` | Where the workstation pull report is **written** |
|
||
| `BACKUPS_INGEST_TOKEN` | — | Bearer token for `POST /backups/pull` (503 if missing) |
|
||
| `BACKUPS_STALE_SECONDS` | `129600` | Age past which a `GET /backups` block reports `stale: true` (non-numeric or <= 0: default) |
|
||
|
||
## 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.
|
||
- `/var/backups/postgres-status` -> `/data/backups-status:ro` — the
|
||
`status.json` written by the VPS backup script (root, directory 755, file
|
||
644, so uid 1000 can read it). Mount the directory, never the file: the
|
||
script replaces it by `rename`. Without this mount `GET /backups` still
|
||
answers, with `vps: null`.
|
||
|
||
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, for `POST /claude-usage` and `POST /backups/pull` only, the
|
||
directory of `CLAUDE_USAGE_PATH` and of `BACKUPS_PULL_PATH` respectively — 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.
|
||
- `<host>/data/claude-usage/` -> `/data/claude-usage/` (directory of
|
||
`CLAUDE_USAGE_PATH`). Same rule: read-write, owned by uid 1000, otherwise
|
||
every `POST /claude-usage` 500s. Mount the directory, never the file: the
|
||
snapshot is replaced by `rename`, which a file bind would not follow.
|
||
- `<host>/data/backups-pull/` -> `/data/backups-pull/` (directory of
|
||
`BACKUPS_PULL_PATH`). Same rule: read-write, owned by uid 1000, the directory
|
||
and not the file. Not writable, every `POST /backups/pull` answers 500 while
|
||
`GET /backups` keeps serving the VPS block.
|
||
|
||
## 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
|
||
```
|