No description
Find a file
le king fu 1b619249e2
All checks were successful
CI / verification (pull_request) Successful in 36s
feat(backups): GET /backups and POST /backups/pull
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>
2026-10-10 19:43:07 -04:00
.forgejo/workflows ci: add a Forgejo Actions workflow with a boot-from-COPY check (#24) 2026-09-27 20:22:48 -04:00
__tests__ feat(backups): GET /backups and POST /backups/pull 2026-10-10 19:43:07 -04:00
agent fix(agent): nothing the VPS sends deletes or alters a pulled backup 2026-10-10 19:13:27 -04:00
scripts docs: correct two comments of the CI pins (#35) 2026-10-01 17:40:38 -04:00
.env.example feat(claude-usage): add POST and GET /claude-usage behind a third token kind 2026-10-04 09:16:05 -04:00
.gitignore feat: initial vps-health-api service 2026-02-26 20:48:09 -05:00
backups.js feat(backups): GET /backups and POST /backups/pull 2026-10-10 19:43:07 -04:00
claude-usage-history.js feat(backups): GET /backups and POST /backups/pull 2026-10-10 19:43:07 -04:00
claude-usage.js feat(server): archive closed Claude usage days by month, serve them on /claude-usage/history 2026-10-08 20:41:30 -04:00
CLAUDE.md feat(backups): GET /backups and POST /backups/pull 2026-10-10 19:43:07 -04:00
Dockerfile feat(backups): GET /backups and POST /backups/pull 2026-10-10 19:43:07 -04:00
index.js feat(backups): GET /backups and POST /backups/pull 2026-10-10 19:43:07 -04:00
metrics.js refactor: extract CPU/RAM/disk collection into metrics.js 2026-08-16 11:30:03 -04:00
package-lock.json feat(defenseurs): add GET /defenseurs/findings?project=X route 2026-05-12 20:56:56 -04:00
package.json feat(defenseurs): add GET /defenseurs/findings?project=X route 2026-05-12 20:56:56 -04:00
README.md feat(backups): GET /backups and POST /backups/pull 2026-10-10 19:43:07 -04:00
test-curl.sh feat(backups): GET /backups and POST /backups/pull 2026-10-10 19:43:07 -04:00
vitest.config.js feat(defenseurs): add GET /defenseurs/findings?project=X route 2026-05-12 20:56:56 -04:00

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); 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/) 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.

Tests

npm install
npm test