|
All checks were successful
CI / verification (pull_request) Successful in 35s
backup-postgres.sh now writes files[], one entry per archived host directory (la-compagnie-maximus#295). sanitizeBackupsStatus() dropped it as an unknown field, so a failed archive turned the status red with no cause. It is now rebuilt from a whitelist like databases[], with a closed code list of its own (source_missing, archive_failed, encrypt_failed, invalid_name), the script's name pattern with a 63 cap, a nullable archivedAt and a bound of 16. A status without files keeps working. Refs #61 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| __tests__ | ||
| agent | ||
| scripts | ||
| .env.example | ||
| .gitignore | ||
| backups.js | ||
| claude-usage-history.js | ||
| claude-usage.js | ||
| CLAUDE.md | ||
| Dockerfile | ||
| index.js | ||
| metrics.js | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| test-curl.sh | ||
| vitest.config.js | ||
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:
{
"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);percent0–100; limits.statusmust be in the enum;limits.detailmust 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/) 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>" }
vpsis thestatus.jsonwritten by the VPS script (backup-postgres.sh, contractschemaVersion: 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, anerroroutside the closed list of codes is dropped (the entry keepsok: false), a database name the script refused is dropped. Its age runs fromgeneratedAt.lenovois the lastPOST /backups/pull, aged on the server'sreceivedAt, never on the workstation'spulledAt.stale = ageSeconds > staleAfterSeconds(BACKUPS_STALE_SECONDS, 36 h), decided by the server only: the admin card and/warmupdisplay it.- A file that is there but unusable gives a
nullblock plusvpsErrororlenovoError(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 content200 { 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 inagents-map.json(e.g.la-suite-booking)category(optional) — exact match, one ofdeps|secrets|code|acces|infraseverity(optional) — threshold, one ofCRITICAL|HIGH|MEDIUM|LOW|INFO- default (no param):
MEDIUM,HIGH,CRITICAL LOWreturnsLOW+MEDIUM+HIGH+CRITICALbut still hidesINFOINFOreturnsINFOonly (explicit opt-in)
- default (no param):
Responses:
200 { agent, project, timestamp, findings: Finding[] }— report present (emptyfindingsif clean scan; nostatusfield)200 { findings: [], status: "no_data" }— no report on file for the agent400— missingprojector invalidcategory/severity401— missing or invalid token404— unknown project500—agents-map.jsonunreadable 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— holdsstatus.json,findings-state.jsonandagents-map.json, all written there directly by the Sergent. (/home/defenseur/defenseurs/status.jsonis 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— thestatus.jsonwritten 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 byrename. Without this mountGET /backupsstill answers, withvps: 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, thenodeuser the container runs as, otherwise every ingest 500s.<host>/data/claude-usage/->/data/claude-usage/(directory ofCLAUDE_USAGE_PATH). Same rule: read-write, owned by uid 1000, otherwise everyPOST /claude-usage500s. Mount the directory, never the file: the snapshot is replaced byrename, which a file bind would not follow.<host>/data/backups-pull/->/data/backups-pull/(directory ofBACKUPS_PULL_PATH). Same rule: read-write, owned by uid 1000, the directory and not the file. Not writable, everyPOST /backups/pullanswers 500 whileGET /backupskeeps 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.
Tests
npm install
npm test