# 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/` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot | Routing resolves before authentication: an unknown path or a wrong method answers `404`, never `401`. ### `POST /hosts/` Body: the `GET /health` payload minus `logto` and `timestamp` — `hostname`, `uptime`, `cpu{model,cores,loadAvg,usagePercent}`, `memory{totalGB,usedGB,freeGB,usagePercent}`, `disk{…same four…}`. `` 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 | `` well-formed but not in the allowlist | | 404 | `` 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/` (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: - `/data/hosts/` -> `/data/hosts/` (`HOSTS_DIR`). Must be writable by uid 1000, the `node` user the container runs as, otherwise every ingest 500s. ## Tests ``` npm install npm test ```