Compare commits

...

6 commits

Author SHA1 Message Date
le king fu
e181a9691c fix(review): close the three gaps found in stack review
Follow-up to the /pr-review pass on PRs #18-#22. Three findings, none of
which changed behaviour, all of which weakened a guarantee the stack was
supposed to provide.

1. auth.test.js carried "any new route must be added here" but /hosts was
   never added when #13 introduced it, so no test asserted GET /hosts -> 401
   on a missing header. The drift class the file exists to catch slipped on
   its first outing. ROUTES now covers /hosts, and a dedicated block pins
   both directions of the token separation: a read token cannot write, an
   ingest token cannot read.

2. ingestOversized() folded any transport error into 413, so the four
   oversized-body tests would have stayed green if the server had stopped
   writing the status and merely killed the socket - on the one path where
   "a status, not a dead socket" is the whole client contract. Transport
   errors are now surfaced instead of swallowed.

3. HOST_AGENT_TIMEOUT_MS was read by push-metrics.js but never exported by
   run-push.sh, so setting it in the documented env file did nothing. Now
   exported and documented.

Also drops a claim from agent/README.md that the review proved false: the
ingest/read token split buys no containment on the ThinkPad, which already
stores HEALTH_TOKEN in cleartext for defenseur-auto. Losing that laptop
compromises both, so they rotate together.
2026-08-16 14:24:43 -04:00
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
le king fu
56ee580167 test(hosts): grow the workstation API smoke pass into a full matrix
The suite that shipped with the implementation pinned the decisions that
were expensive to get wrong. This turns it into the exhaustive matrix the
surface deserves: POST /hosts/<id> is the only publicly writable endpoint
on the service, so a mistake there is an intrusion rather than an outage.

__tests__/hosts.test.js goes from 22 to 155 cases (suite 61 -> 194):

- auth: nine near-miss Authorization headers, the read token refused on
  the write route, the ingest token refused on all five read routes, and
  the two tokens proven independent — either one unset leaves the other
  path working
- rejection ordering: 404 (routing) beats 503 (ingest token unset) beats
  403 (outside the allowlist), and a malformed id answers 404 whatever
  token it carries, so route existence stays unenumerable
- routing: eleven more malformed ids, every one of them a 404. Widening
  HOST_ROUTE_RE to `^/hosts/(.+)$` turns sixteen of them red, which is
  what makes the narrow pattern a traversal control rather than a comment
- payload: twenty-two field mutations, eight bodies that are not a JSON
  object, an Infinity only a raw body can express, and the 4 KiB ceiling
  pinned from both sides — 4096 accepted, 4097 and 8 KiB refused
- persistence: 0600 mode, whitelist rebuild, 128-character cap, loadAvg
  sliced to three, the route id winning over an id in the body, an
  overwrite leaving no temp debris, and the 500 path when HOSTS_DIR
  cannot be created
- freshness: 899/900/901 exact against a frozen Date.now() instead of the
  wall clock, plus a custom HOSTS_STALE_SECONDS and its fallbacks
- listing: eight ways a snapshot file can be corrupt, each degrading its
  own entry while a healthy neighbour keeps its data

The 4 KiB hostname of the acceptance criteria is covered twice, because
the body ceiling fires before the sanitiser ever sees it: 4096 characters
are refused with 413, and 3500 characters (which fit) are truncated to
128 with the long value absent from the file.

index.js is untouched. No defect surfaced, and the two mutations used to
prove the net bites were reverted.

Resolves #14
2026-08-16 12:05:31 -04:00
le king fu
fedb1c81dc feat: ingest and serve workstation snapshots (POST /hosts/<id>, GET /hosts)
Open the API's first write surface. Workstations push their CPU / memory /
disk snapshot, the admin dashboard reads it back with a server-computed
freshness.

Routing moves from an exact path list to a descriptor table, keeping ONE
authentication checkpoint:

  resolveRoute()  -> no match means an immediate 404, deny by default
  checkAuth()     -> the single gate; only the expected token varies per
                     descriptor (HEALTH_TOKEN for reads, HOSTS_INGEST_TOKEN
                     for ingestion)
  dispatch

The routing-before-auth ordering is preserved on purpose: an unknown path or
a wrong method still answers 404, never 401, exactly as before. The two
`404 (not 401)` tests added in #12 pin that ordering and still pass.

Security controls on the new write path:

- The route regex ^/hosts/([a-z0-9][a-z0-9-]{0,31})$, POST-only, IS the path
  traversal control. URL() normalises /hosts/../../etc/passwd to /etc/passwd
  and encoded traversals fail the match, so every hostile id lands on 404.
  403 is reserved for well-formed ids outside the allowlist.
- 401 wins over 403, so an unauthenticated caller cannot probe the allowlist.
- tokenMatches() compares SHA-256 digests with timingSafeEqual. Scoped to the
  ingestion path only; realigning HEALTH_TOKEN touches every read route in
  production and is tracked separately.
- Body capped at 4 KiB by counting received bytes, never Content-Length:
  a client can lie in the header and chunked encoding omits it. Past the cap,
  413 then req.destroy() once the response has flushed.
- The persisted object is rebuilt field by field from a whitelist — finite
  numbers, strings capped at 128 chars, everything else dropped — because the
  file is read back by GET /hosts and ends up in the admin React tree. The
  read path re-runs the same whitelist: the bind-mount is writable, so the
  file on disk earns no more trust than the payload did.
- Snapshots are written to a temp file in the same directory then renamed, so
  a concurrent GET /hosts can never observe a truncated JSON.
- HOSTS_ALLOWED_IDS entries are validated at startup with the same regex;
  rejects are logged and dropped rather than becoming file paths.
- Every 401/403 is logged with X-Real-IP, the host id and the reason. Log
  values are filtered to printable ASCII so a crafted header cannot forge
  extra log lines.
- receivedAt is stamped by the server on arrival; a client-supplied timestamp
  is discarded by the whitelist. online = ageSeconds <= HOSTS_STALE_SECONDS.

Runtime stays zero-dependency — node:crypto is a builtin and no new module
file was added, so the Dockerfile's explicit COPY list is unchanged.

Tests: 61 (39 existing untouched + 22 new). __tests__/hosts.test.js is smoke
coverage of the decisions that would be silent to regress; the exhaustive
matrix is issue #14.

Docs: .env.example gains HOSTS_DIR / HOSTS_ALLOWED_IDS / HOSTS_INGEST_TOKEN /
HOSTS_STALE_SECONDS; CLAUDE.md and README.md document the endpoints, the
routing/auth ordering and the config. The CLAUDE.md "read-only" gotcha is
corrected — the API now writes, and HOSTS_DIR must be writable by uid 1000.

Resolves #13
2026-08-16 11:49:36 -04:00
le king fu
babfd1f4b9 test(auth): cover the 401 gate on all four read routes
The 14 existing tests all hit /defenseurs/findings — /health, /defenseurs
and /reports/scans had no authentication coverage at all. This is the
safety net for the routing/auth refactor that comes next: a miswiring
could expose the parc-wide Defenseurs reports publicly without turning a
single test red.

Adds __tests__/auth.test.js (19 tests), following the findings.test.js
pattern (real http server + temp dir):
- 401 on all four routes with no Authorization header
- 401 on all four routes with a wrong bearer token
- 401 on malformed headers (no scheme, lowercase scheme, scheme only)
- 401 fail-closed when HEALTH_TOKEN is unset
- 404 on unknown routes and on POST against existing routes

These describe current behaviour: index.js is untouched. Two tests
document that route/method validation runs before authentication, so an
unauthenticated caller gets 404 rather than 401 on those paths.

Resolves #12
2026-08-16 11:36:05 -04:00
le king fu
79cb813767 refactor: extract CPU/RAM/disk collection into metrics.js
Move readProcStat, getCpuPercent, getDisk and the cpu/memory/disk payload
assembly out of index.js into a standalone metrics.js, so the upcoming
local workstation agent can reuse the same collection code. No behaviour
change: /health returns the exact same fields, in the same order.

Notes:
- collectMetrics() returns the { cpu, memory, disk } slice; getHealth()
  spreads it, keeping the JSON key order the admin dashboard relies on.
- getHealth() keeps Promise.all([collectMetrics(), getLogtoHealth()]).
  The 500ms CPU sample and the 3s Logto check are deliberately concurrent;
  serializing them would push the p99 of /health to ~3.5s.
- collectMetrics() awaits the CPU sample as its only await, so callers
  running it inside a Promise.all keep their concurrency.
- Dockerfile COPY lists files one by one, so metrics.js had to be added
  there or the container would crash on MODULE_NOT_FOUND at startup.
- New __tests__/health.test.js: metrics.js exports, field-for-field
  payload shape, and a latency guard (<1.5s with a 1200ms stubbed Logto).
  Verified by mutation: serializing the two calls fails the latency test
  at ~1743ms while the field comparison still passes.
- Runtime stays 0-dependency; the 14 existing tests are untouched.

Resolves #11
2026-08-16 11:30:03 -04:00
13 changed files with 3332 additions and 197 deletions

View file

@ -7,3 +7,19 @@ LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-con
# Directory served by GET /reports/scans. Bind-mount target on Coolify —
# parent /data/defenseurs/ is already mounted (status.json sits next to it).
REPORTS_DIR=/data/defenseurs/reports
# --- Workstation snapshots (POST /hosts/<id>, GET /hosts) --------------------
# Directory the API WRITES workstation snapshots into, one <id>.json per host.
# Unlike the /data/defenseurs mounts this one must be writable by uid 1000
# (the `node` user the container runs as) or every ingest answers 500.
HOSTS_DIR=/data/hosts
# Comma-separated allowlist of host ids. Each entry must match
# ^[a-z0-9][a-z0-9-]{0,31}$ — anything else is logged and dropped at startup.
HOSTS_ALLOWED_IDS=thinkpad
# Bearer token for POST /hosts/<id>. Separate from HEALTH_TOKEN on purpose: a
# workstation agent should be able to write its own snapshot without gaining
# read access to the Defenseurs reports. Same Coolify rule as HEALTH_TOKEN —
# is_runtime=true, is_buildtime=false. Unset -> POST /hosts/<id> answers 503.
HOSTS_INGEST_TOKEN=change-me-to-a-strong-secret
# Age (seconds) past which GET /hosts reports a host as offline. Default 900.
HOSTS_STALE_SECONDS=900

View file

@ -1,6 +1,6 @@
# VPS Health API
API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
## Endpoints
@ -9,11 +9,24 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
- `GET /reports/scans?date=YYYY-MM-DD` — agrege les rapports `defenseur-<agent>_<date>*.json` du jour, format `{ date, count, reports: Report[] }`. Filtre `isScanReport` (exclut `defenseur-auto_*.json`). Date validee par regex (path traversal bloque). Consommateur : `defenseur-auto` workstation cron (remplace le pre-rsync SSH). Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/reports/scans?date=2026-05-07"`.
- `GET /defenseurs/findings?project=X` — findings detailles du Defenseur correspondant. Query params : `project=<name>` (obligatoire, lookup via `agents-map.json`), `category=<deps|secrets|code|acces|infra>` (optionnel, exact match), `severity=<CRITICAL|HIGH|MEDIUM|LOW|INFO>` (optionnel, threshold inclusif vers le haut). Sans `severity` -> MEDIUM+HIGH+CRITICAL (cache LOW+INFO). `severity=LOW` -> LOW+MEDIUM+HIGH+CRITICAL (cache toujours INFO, asymetrie volontaire). `severity=INFO` -> INFO uniquement (opt-in explicite). Reponses : 200 `{ agent, project, timestamp, findings[] }` si report present (sans champ `status`) ; 200 `{ findings: [], status: "no_data" }` si pas de report ; 400 sans project ou param invalide ; 404 projet inconnu ; 500 si `agents-map.json` corrompu. Consommateurs : admin dashboard Vercel (drill-down), futur skill `/analyse-vulnerabilite`. Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"`.
## Auth
- `GET /hosts` — dernier snapshot de chaque poste de l'allowlist. Format `{ staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }`. `online = ageSeconds <= HOSTS_STALE_SECONDS` (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en `{ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }` sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel.
- `POST /hosts/<id>`**seule surface d'ecriture de l'API**. Auth par `HOSTS_INGEST_TOKEN` (pas `HEALTH_TOKEN`). Corps = payload de `GET /health` moins `logto` et `timestamp`. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 `<id>` bien forme mais hors allowlist / 404 `<id>` hors format / 413 corps > 4 Kio (connexion coupee) / 503 `HOSTS_INGEST_TOKEN` non configure. Exemple : `curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad"`.
- Bearer token via env `HEALTH_TOKEN`
- Fail-closed : si `HEALTH_TOKEN` non configure, toutes les requetes sont refusees
- **Coolify** : `HEALTH_TOKEN` doit etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
## Routage et auth
Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` :
1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token.
2. `checkAuth()`**un seul point de controle**, dont seul le token attendu varie (`tokenKind: "read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee.
3. Dispatch vers le handler.
Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord.
- Fail-closed : `HEALTH_TOKEN` absent -> 401 sur les lectures ; `HOSTS_INGEST_TOKEN` absent -> 503 sur l'ingestion
- `tokenMatches()` (`timingSafeEqual` sur empreintes SHA-256) protege **uniquement** le chemin d'ingestion. Le realignement du `HEALTH_TOKEN` existant est suivi separement (issue #17) : il toucherait tous les chemins de lecture en production
- 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist
- Chaque rejet 401/403 est journalise (`[auth] <code> <method> <url> ip=<X-Real-IP> id=<host> reason=<motif>`), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log
- **Coolify** : `HEALTH_TOKEN` et `HOSTS_INGEST_TOKEN` doivent etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
## Config
@ -21,9 +34,14 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
- `REPORTS_DIR` : dossier lu par `/reports/scans` et `/defenseurs/findings` (default `/data/defenseurs/reports`)
- `DEFENSEURS_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`)
- `HOSTS_DIR` : dossier **ecrit** par `POST /hosts/<id>`, un `<id>.json` par poste (default `/data/hosts`)
- `HOSTS_ALLOWED_IDS` : allowlist separee par virgules (default `thinkpad` — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee
- `HOSTS_INGEST_TOKEN` : token d'ingestion, distinct de `HEALTH_TOKEN` (pas de default — absent = 503)
- `HOSTS_STALE_SECONDS` : age au-dela duquel un poste est reporte hors ligne (default 900)
- Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) :
- `/data/defenseurs` (host) -> `/data/defenseurs` : `status.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne.
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10).
- `/data/defenseurs` (host) -> `/data/defenseurs` : `status.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Lecture seule** pour cette API.
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10). **Lecture seule** pour cette API.
- `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500.
## Deploy
@ -38,13 +56,24 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests
- `npm test` (vitest) — couvre `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
- `npm test` (vitest) — 244 cas
- `__tests__/findings.test.js``/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
- `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence)
- `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (19 cas : matrice route x mode d'echec, en-tetes malformes, fail-closed, et **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**)
- `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (155 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture)
- `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**)
- Runtime reste 0-dep ; vitest en devDep uniquement
## Gotchas
- Pas d'Express — HTTP natif Node.js uniquement
- Le `status.json` et `agents-map.json` sont ecrits par le Sergent defenseurs, pas par cette API (read-only)
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que les tests locaux ne voient pas. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais.
- `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env.
- `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`.
- L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/<id>` ecrit dans `HOSTS_DIR`. Consequence : ce montage-la doit etre en lecture-ecriture pour l'uid 1000, contrairement aux montages `/data/defenseurs`.
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync``rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct.
- L'objet persiste est **reconstruit champ par champ** depuis une liste blanche (nombres via `Number.isFinite`, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par `GET /hosts` et finit dans l'arbre React de l'admin : une cle `__proto__` ou un `hostname` de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload.
- Le plafond de corps a 4 Kio compte les octets **recus**, pas le `Content-Length` : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis `req.destroy()` une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code).
- `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500.
- Coolify ignore silencieusement `-v` dans `custom_docker_run_options` — les volumes passent par les Persistent Storages (UI seulement). Details dans `la-compagnie-maximus/docs/coolify-ops.md`.
- Severity threshold est asymetrique : `?severity=LOW` retourne LOW+MEDIUM+HIGH+CRITICAL mais cache INFO. INFO est seulement accessible via `?severity=INFO` explicite (cache le bruit par defaut).

View file

@ -1,6 +1,6 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json index.js ./
COPY package.json index.js metrics.js ./
EXPOSE 3001
USER node
CMD ["node", "index.js"]

View file

@ -4,14 +4,68 @@ Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtim
## Endpoints
All endpoints require `Authorization: Bearer $HEALTH_TOKEN`.
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 | Description |
|--------|------|-------------|
| GET | `/health` | CPU, memory, disk, uptime, Logto reachability |
| GET | `/defenseurs` | Defenseurs executive status (status.json) |
| GET | `/defenseurs/findings?project=X` | Detailed findings for a project's Defenseur |
| GET | `/reports/scans?date=YYYY-MM-DD` | Aggregated scan reports for a UTC date |
| 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`
@ -45,17 +99,35 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \
| Env var | Default | Purpose |
|---------|---------|---------|
| `PORT` | `3001` | HTTP port |
| `HEALTH_TOKEN` | — | Bearer token (fail-closed if missing) |
| `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)
## 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
```

597
__tests__/agent.test.js Normal file
View file

@ -0,0 +1,597 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const {
buildSnapshot,
buildTargetUrl,
postSnapshot,
readConfig,
DEFAULT_TIMEOUT_MS,
EXIT_CONFIG,
EXIT_PUSH,
} = require("../agent/push-metrics.js");
const { collectMetrics } = require("../metrics.js");
const { sanitizeSnapshot } = require("../index.js");
// Coverage for the local workstation agent (issue #15).
//
// Three things are worth stating up front, because they explain why this file
// spends most of its length on failure paths rather than on the happy one.
//
// * THE TOKEN MUST NOT SURVIVE INTO A LOG LINE. run-push.sh pipes the agent's
// output into `logger -t host-agent`; anything printed lands in syslog and
// journald for good. Every failure path here is therefore checked against
// every 5-character-or-longer fragment of the token — message, stack, JSON
// rendering — and the two spawned-process cases check the real stdout and
// stderr of a real run, which is what `logger` would actually swallow.
// * NO QUEUE, NO REPLAY. A failed push is dropped, not retried: the
// assertions count the requests the server saw and expect exactly one.
// * The payload contract is pinned against the server's own
// sanitizeSnapshot(), and one case pushes a REAL collectMetrics() snapshot
// through the REAL ingestion handler. A drift between agent and server
// shows up as a red test rather than as a 400 at 3 a.m. on the ThinkPad.
const AGENT_PATH = path.join(__dirname, "..", "agent", "push-metrics.js");
const RUNNER_PATH = path.join(__dirname, "..", "agent", "run-push.sh");
// Mixed letters and digits, no dictionary word and no run of five digits, so a
// port number or a timestamp in the output can never look like a leak.
const TOKEN = "7f2a9c4e1b6d8035a5e0c3d1";
const HOST_ID = "thinkpad";
const servers = [];
let tmpDir;
function fakeMetrics() {
return {
cpu: {
model: "Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz",
cores: 8,
loadAvg: [0.42, 0.31, 0.25],
usagePercent: 12,
},
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
};
}
async function startServer(onRequest) {
const server = http.createServer(onRequest);
servers.push(server);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return { server, url: `http://127.0.0.1:${server.address().port}` };
}
// A recording server that answers with the given status. Returns the log of
// what it saw, so "exactly one request" and "the Authorization header was sent"
// are assertions rather than assumptions.
async function startRecordingServer(status = 204) {
const seen = [];
const { url, server } = await startServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
seen.push({
method: req.method,
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString("utf-8"),
});
res.writeHead(status);
res.end();
});
});
return { url, server, seen };
}
async function closeServers() {
while (servers.length) {
const server = servers.pop();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}
// Every substring of the token from `min` characters up. A failure message that
// contains none of them cannot contain the token, nor a truncated half of it.
function tokenFragments(token, min = 5) {
const out = [];
for (let len = min; len <= token.length; len++) {
for (let i = 0; i + len <= token.length; i++) out.push(token.slice(i, i + len));
}
return out;
}
function expectNoTokenLeak(text, token = TOKEN) {
const haystack = String(text);
const leaked = tokenFragments(token).filter((frag) => haystack.includes(frag));
// Report the longest match only: every shorter fragment of it also matches,
// and dumping the 200-odd of them buries the failure it is reporting.
const worst = leaked.reduce((a, b) => (b.length > a.length ? b : a), "");
expect(
leaked.length,
`token leaked into the output — longest fragment found: "${worst}"`,
).toBe(0);
}
// Everything a careless `console.error(err)` would print.
function renderError(err) {
return [
err.message,
String(err),
err.stack,
JSON.stringify(err),
JSON.stringify(err, Object.getOwnPropertyNames(err)),
].join("\n");
}
async function pushTo(url, overrides = {}) {
return postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 2000,
...overrides,
});
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-test-"));
});
afterEach(async () => {
await closeServers();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("payload — matches the POST /hosts/<id> contract", () => {
test("the built snapshot passes the server's sanitizeSnapshot() unchanged", () => {
const snapshot = buildSnapshot(fakeMetrics());
const result = sanitizeSnapshot(snapshot);
expect(result.error).toBeUndefined();
expect(result.snapshot).toEqual(snapshot);
});
test("exactly the documented keys, nothing more", () => {
const snapshot = buildSnapshot(fakeMetrics());
expect(Object.keys(snapshot).sort()).toEqual([
"cpu",
"disk",
"hostname",
"memory",
"uptime",
]);
expect(Object.keys(snapshot.cpu).sort()).toEqual([
"cores",
"loadAvg",
"model",
"usagePercent",
]);
for (const key of ["memory", "disk"]) {
expect(Object.keys(snapshot[key]).sort()).toEqual([
"freeGB",
"totalGB",
"usagePercent",
"usedGB",
]);
}
});
test("an extra field added to collectMetrics() does not leave the workstation", () => {
const metrics = fakeMetrics();
metrics.temperatureC = 61;
metrics.cpu.serial = "PF0X1234";
const snapshot = buildSnapshot(metrics);
expect(snapshot.temperatureC).toBeUndefined();
expect(snapshot.cpu.serial).toBeUndefined();
});
test("hostname and uptime come from the host, uptime floored like getHealth()", () => {
const snapshot = buildSnapshot(fakeMetrics());
expect(snapshot.hostname).toBe(os.hostname());
expect(Number.isInteger(snapshot.uptime)).toBe(true);
expect(snapshot.uptime).toBeGreaterThan(0);
});
test("a real collectMetrics() snapshot is accepted by sanitizeSnapshot()", async () => {
const snapshot = buildSnapshot(await collectMetrics());
const result = sanitizeSnapshot(snapshot);
expect(result.error).toBeUndefined();
expect(result.snapshot).toEqual(snapshot);
});
});
describe("delivery — one attempt, no queue", () => {
test("POSTs to /hosts/<id> with the bearer token and resolves on 204", async () => {
const { url, seen } = await startRecordingServer(204);
// Build ONCE and compare against that object. Two calls to buildSnapshot()
// straddling a second boundary differ by 1 on `uptime`, which turns this
// assertion into a one-in-several-runs failure.
const snapshot = buildSnapshot(fakeMetrics());
const result = await pushTo(url, { snapshot });
expect(result.statusCode).toBe(204);
expect(seen).toHaveLength(1);
expect(seen[0].method).toBe("POST");
expect(seen[0].url).toBe(`/hosts/${HOST_ID}`);
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(seen[0].headers["content-type"]).toBe("application/json");
expect(JSON.parse(seen[0].body)).toEqual(snapshot);
});
test("any 2xx counts as delivered", async () => {
const { url } = await startRecordingServer(200);
await expect(pushTo(url)).resolves.toEqual({ statusCode: 200 });
});
test.each([400, 401, 403, 413, 500, 503])("rejects on HTTP %i", async (status) => {
const { url } = await startRecordingServer(status);
await expect(pushTo(url)).rejects.toThrow(`push failed: HTTP ${status}`);
});
test("a rejected push is dropped, never retried", async () => {
const { url, seen } = await startRecordingServer(500);
await expect(pushTo(url)).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 150));
expect(seen).toHaveLength(1);
});
test("reports only err.code when the connection is refused", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
await expect(pushTo(url)).rejects.toThrow("push failed: request error (code=ECONNREFUSED)");
});
test("gives up on a server that never answers, as ETIMEDOUT", async () => {
const { url } = await startServer(() => {
/* never responds */
});
const started = Date.now();
await expect(pushTo(url, { timeoutMs: 200 })).rejects.toThrow(
"push failed: request error (code=ETIMEDOUT)",
);
expect(Date.now() - started).toBeLessThan(2000);
});
test("the default timeout is 10s", () => {
expect(DEFAULT_TIMEOUT_MS).toBe(10000);
});
test("a trailing slash on HOSTS_API_URL does not double up", () => {
expect(buildTargetUrl("https://health.example.com/", "thinkpad").href).toBe(
"https://health.example.com/hosts/thinkpad",
);
expect(buildTargetUrl("https://health.example.com", "thinkpad").href).toBe(
"https://health.example.com/hosts/thinkpad",
);
});
test.each([
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
["not a url", "HOSTS_API_URL is not a valid URL"],
])("refuses %s before opening a socket", async (apiUrl, message) => {
await expect(pushTo(apiUrl)).rejects.toThrow(message);
});
});
describe("the failure path never carries the token", () => {
test("HTTP failure: message, stack and JSON rendering are all clean", async () => {
const { url } = await startRecordingServer(500);
const err = await pushTo(url).catch((e) => e);
expect(err.message).toBe("push failed: HTTP 500");
expectNoTokenLeak(renderError(err));
});
test("transport failure: message, stack and JSON rendering are all clean", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
const err = await pushTo(url).catch((e) => e);
expectNoTokenLeak(renderError(err));
});
test("timeout failure: message, stack and JSON rendering are all clean", async () => {
const { url } = await startServer(() => {});
const err = await pushTo(url, { timeoutMs: 200 }).catch((e) => e);
expectNoTokenLeak(renderError(err));
});
test("a token with a trailing newline fails without quoting itself", async () => {
const { url } = await startRecordingServer(204);
const err = await pushTo(url, { token: `${TOKEN}\n` }).catch((e) => e);
expect(err.message).toContain("HOSTS_INGEST_TOKEN contains an invalid character");
expectNoTokenLeak(renderError(err));
});
test("the real process output of a failing run contains no fragment of the token", async () => {
const { url } = await startRecordingServer(500);
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
env: {
PATH: process.env.PATH,
HOSTS_API_URL: url,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
},
}).catch((e) => e);
expect(result.code).toBe(EXIT_PUSH);
expect(result.stderr).toContain("host-agent: push failed: HTTP 500");
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("the real process output of an unreachable server contains no fragment of the token", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
env: {
PATH: process.env.PATH,
HOSTS_API_URL: url,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
},
}).catch((e) => e);
expect(result.code).toBe(EXIT_PUSH);
expect(result.stderr).toContain("code=ECONNREFUSED");
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
});
describe("configuration", () => {
test.each([
["HOSTS_API_URL", "HOSTS_API_URL is not set"],
["HOSTS_INGEST_TOKEN", "HOSTS_INGEST_TOKEN is not set"],
["HOST_ID", "HOST_ID is not set"],
])("a missing %s is named, not guessed", (missing, message) => {
const env = { HOSTS_API_URL: "https://x.test", HOSTS_INGEST_TOKEN: TOKEN, HOST_ID };
delete env[missing];
const { error, config } = readConfig(env);
expect(config).toBeUndefined();
expect(error.message).toContain(message);
expectNoTokenLeak(renderError(error));
});
test.each(["THINKPAD", "-thinkpad", "think pad", "a".repeat(33), "../thinkpad"])(
"rejects the malformed HOST_ID %j client-side",
(hostId) => {
const { error } = readConfig({
HOSTS_API_URL: "https://x.test",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID: hostId,
});
expect(error.message).toContain("HOST_ID must match");
},
);
test.each([
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
["not a url", "HOSTS_API_URL is not a valid URL"],
])("a broken HOSTS_API_URL (%j) is an install error, not a push error", (apiUrl, message) => {
const { error, config } = readConfig({
HOSTS_API_URL: apiUrl,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
});
expect(config).toBeUndefined();
expect(error.message).toContain(message);
});
test("accepts a well-formed config and defaults the timeout", () => {
const { config } = readConfig({
HOSTS_API_URL: "https://health.example.com",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
});
expect(config).toEqual({
apiUrl: "https://health.example.com",
token: TOKEN,
hostId: HOST_ID,
timeoutMs: DEFAULT_TIMEOUT_MS,
});
});
test("--dry-run prints the payload and touches no network", async () => {
const { stdout } = await execFileAsync(process.execPath, [AGENT_PATH, "--dry-run"], {
env: { PATH: process.env.PATH },
});
const snapshot = JSON.parse(stdout);
expect(sanitizeSnapshot(snapshot).error).toBeUndefined();
expect(snapshot.hostname).toBe(os.hostname());
});
});
describe("run-push.sh", () => {
function writeEnvFile(lines, mode = 0o600) {
const file = path.join(tmpDir, "maximus-host-agent.env");
fs.writeFileSync(file, `${lines.join("\n")}\n`, { mode });
return file;
}
async function runWrapper(envFile) {
return execFileAsync(RUNNER_PATH, [], {
env: { PATH: process.env.PATH, HOME: tmpDir, HOST_AGENT_ENV_FILE: envFile },
}).catch((e) => e);
}
test("delivers the snapshot end to end", async () => {
const { url, seen } = await startRecordingServer(204);
const envFile = writeEnvFile([
`HOSTS_API_URL=${url}`,
`HOSTS_INGEST_TOKEN=${TOKEN}`,
`HOST_ID=${HOST_ID}`,
`NODE_BIN=${process.execPath}`,
]);
const result = await runWrapper(envFile);
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`host-agent: ok id=${HOST_ID} status=204`);
expect(seen).toHaveLength(1);
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(sanitizeSnapshot(JSON.parse(seen[0].body)).error).toBeUndefined();
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test.each([
[["HOSTS_INGEST_TOKEN", "HOST_ID"], "HOSTS_API_URL is not set"],
[["HOSTS_API_URL", "HOST_ID"], "HOSTS_INGEST_TOKEN is not set"],
[["HOSTS_API_URL", "HOSTS_INGEST_TOKEN"], "HOST_ID is not set"],
])("stops when a variable is missing (%j)", async (present, message) => {
const values = {
HOSTS_API_URL: "https://health.example.com",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
};
const envFile = writeEnvFile(present.map((key) => `${key}=${values[key]}`));
const result = await runWrapper(envFile);
expect(result.code).toBe(1);
expect(result.stderr).toContain(message);
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("stops when the env file is absent", async () => {
const missing = path.join(tmpDir, "nope.env");
const result = await runWrapper(missing);
expect(result.code).toBe(1);
expect(result.stderr).toContain(`host-agent: env file not found: ${missing}`);
});
test("warns about a world-readable env file but still runs", async () => {
const { url, seen } = await startRecordingServer(204);
const envFile = writeEnvFile(
[
`HOSTS_API_URL=${url}`,
`HOSTS_INGEST_TOKEN=${TOKEN}`,
`HOST_ID=${HOST_ID}`,
`NODE_BIN=${process.execPath}`,
],
0o644,
);
const result = await runWrapper(envFile);
expect(result.stderr).toContain("is mode 644, expected 600");
expect(seen).toHaveLength(1);
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("never enables shell tracing — `set -x` would echo the token into syslog", () => {
const script = fs.readFileSync(RUNNER_PATH, "utf-8");
const traced = script
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.filter((line) => /\bset\b[^#\n]*-[a-z]*x/.test(line));
expect(traced).toEqual([]);
});
test("is executable", () => {
expect(fs.statSync(RUNNER_PATH).mode & 0o111).toBeGreaterThan(0);
});
});
describe("the agent stays out of the server image", () => {
test("the Dockerfile copies files one by one and none of them is the agent", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
const copies = dockerfile
.split("\n")
.filter((line) => /^\s*(COPY|ADD)\b/i.test(line));
expect(copies).toHaveLength(1);
expect(copies[0]).not.toMatch(/agent/);
// A bare `COPY . .` would drag agent/ in without ever naming it.
expect(copies[0]).not.toMatch(/^\s*COPY\s+\.\s/i);
});
});
describe("against the real ingestion handler", () => {
const savedEnv = {};
const ENV_KEYS = ["HOSTS_INGEST_TOKEN", "HOSTS_DIR", "HOSTS_ALLOWED_IDS", "HEALTH_TOKEN"];
beforeEach(() => {
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
process.env.HOSTS_INGEST_TOKEN = TOKEN;
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = HOST_ID;
process.env.HEALTH_TOKEN = "read-token";
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
});
test("a real snapshot is accepted with 204 and persisted field for field", async () => {
// Fresh import so index.js picks up the env above.
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
const snapshot = buildSnapshot(await collectMetrics());
const result = await postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot,
timeoutMs: 5000,
});
expect(result.statusCode).toBe(204);
const written = JSON.parse(
fs.readFileSync(path.join(process.env.HOSTS_DIR, `${HOST_ID}.json`), "utf-8"),
);
expect(written.id).toBe(HOST_ID);
expect(typeof written.receivedAt).toBe("string");
delete written.id;
delete written.receivedAt;
expect(written).toEqual(snapshot);
});
test("a wrong ingest token is rejected with 401 and no fragment leaks", async () => {
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
const err = await postSnapshot({
apiUrl: url,
token: "wrong-token",
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 5000,
}).catch((e) => e);
expect(err.message).toBe("push failed: HTTP 401");
expectNoTokenLeak(renderError(err));
});
test("a host id outside the allowlist surfaces as HTTP 403", async () => {
process.env.HOSTS_ALLOWED_IDS = "someone-else";
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
await expect(
postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 5000,
}),
).rejects.toThrow("push failed: HTTP 403");
});
});

201
__tests__/auth.test.js Normal file
View file

@ -0,0 +1,201 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// Authentication safety net for the four read routes.
//
// findings.test.js and health.test.js cover what each route *returns*; this
// file covers the gate in front of all of them. The small overlap on
// /defenseurs/findings and /health is deliberate: the value of this file is the
// complete route x failure-mode matrix in one place, so a routing/auth refactor
// that only rewires part of the table still fails loudly here.
//
// These tests describe the CURRENT behaviour of index.js. If one of them turns
// red after a refactor, the refactor changed the security contract.
const TOKEN = "test-token";
// Every GET route reachable by the handler. Any new route must be added here.
// POST /hosts/<id> is not a GET, so it gets its own describe block below —
// but it is covered, and any future route must be too.
const ROUTES = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans", "/hosts"];
// Ingest token, distinct from the read token on purpose: a read token must not
// open the write path, and a write token must not open the read routes.
const INGEST_TOKEN = "test-ingest-token";
let tmpDir;
let server;
let baseUrl;
let handler;
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
({ handler } = require("../index.js"));
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address();
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
});
}
function stopServer() {
return new Promise((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
});
}
async function restartServer() {
await stopServer();
await startServer();
}
async function request(path, { method = "GET", auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${path}`, { method, headers });
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-auth-test-"));
process.env.HEALTH_TOKEN = TOKEN;
// Point every filesystem read at the temp dir so a rejected request can never
// fall back to the host's real /data/defenseurs.
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
// Configured on purpose: an unset ingest token answers 503 before the header
// is ever read, which would hide the 401 these tests are here to pin.
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
// Closed port: /health must never reach the real IdP. If the auth gate ever
// fails open, the request errors out fast instead of hitting production.
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
await startServer();
});
afterEach(async () => {
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("auth gate — missing Authorization header", () => {
test.each(ROUTES)("401 on GET %s with no Authorization header", async (route) => {
const { status, body } = await request(route, { auth: null });
expect(status).toBe(401);
// Pin the gate itself: auth runs before any query-param validation, so
// /defenseurs/findings and /reports/scans must answer 401, never 400.
expect(body.error).toBe("Unauthorized");
});
});
describe("auth gate — invalid token", () => {
test.each(ROUTES)("401 on GET %s with a wrong bearer token", async (route) => {
const { status, body } = await request(route, { auth: "Bearer wrong-token" });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
});
describe("auth gate — malformed Authorization header", () => {
// The header is compared byte for byte against `Bearer <token>`, so these
// near-misses are all rejected. /defenseurs stands in for the four routes:
// it serves the parc-wide Defenseurs report and is the costliest to leak.
const MALFORMED = [
["raw token without the Bearer scheme", TOKEN],
["lowercase scheme", `bearer ${TOKEN}`],
["scheme with no token", "Bearer"],
];
test.each(MALFORMED)("401 on GET /defenseurs — %s", async (_label, header) => {
const { status, body } = await request("/defenseurs", { auth: header });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
});
describe("auth gate — fail-closed when HEALTH_TOKEN is unset", () => {
test("401 on GET /defenseurs even with a well-formed bearer token", async () => {
delete process.env.HEALTH_TOKEN;
await restartServer();
const { status, body } = await request("/defenseurs", { auth: `Bearer ${TOKEN}` });
expect(status).toBe(401);
expect(body.error).toBe("HEALTH_TOKEN not configured");
});
});
describe("routing", () => {
test("404 on an unknown route", async () => {
const { status, body } = await request("/nope");
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
test.each(ROUTES)("404 on POST %s (method not allowed)", async (route) => {
const { status, body } = await request(route, { method: "POST" });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
// Current ordering: the route/method check runs BEFORE authentication, so an
// unauthenticated caller gets 404 rather than 401 on these. Documented as-is;
// any refactor that flips the order will turn these red on purpose.
test("404 (not 401) on an unknown route without Authorization", async () => {
const { status, body } = await request("/nope", { auth: null });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
test("404 (not 401) on POST /health without Authorization", async () => {
const { status, body } = await request("/health", { method: "POST", auth: null });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
});
// The ingest path is the only write surface on this service, and the only one
// reachable with a token that is NOT the read token. Both directions of that
// separation are pinned here: a read token must not write, a write token must
// not read. The gate answers before the request body is ever buffered, so an
// unauthenticated caller cannot make the server hold 4 KiB on its behalf.
describe("auth gate — ingest route", () => {
test("401 on POST /hosts/<id> with no Authorization header", async () => {
const { status, body } = await request("/hosts/thinkpad", { method: "POST", auth: null });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 on POST /hosts/<id> with the read token", async () => {
const { status, body } = await request("/hosts/thinkpad", {
method: "POST",
auth: `Bearer ${TOKEN}`,
});
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 on GET /hosts with the ingest token", async () => {
const { status, body } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
// An unknown host id never reaches the handler: the route regex is the
// path-traversal control, so a malformed id is a 404, not a 403 — and that
// 404 arrives before authentication, like every other routing decision.
test("404 (not 401, not 403) on POST /hosts/../../etc/passwd", async () => {
const { status } = await request("/hosts/../../etc/passwd", { method: "POST", auth: null });
expect(status).toBe(404);
});
});

139
__tests__/health.test.js Normal file
View file

@ -0,0 +1,139 @@
const http = require("node:http");
const { setTimeout: delay } = require("node:timers/promises");
const { getCpuPercent, getDisk, collectMetrics } = require("../metrics.js");
const TOKEN = "test-token";
let logtoServer;
let logtoUrl;
let logtoDelayMs;
let server;
let baseUrl;
let handler;
// Stand-in for the Logto .well-known endpoint, with a settable delay so we can
// simulate a slow IdP without touching the network.
function startLogtoStub() {
logtoDelayMs = 0;
logtoServer = http.createServer(async (req, res) => {
if (logtoDelayMs > 0) await delay(logtoDelayMs);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ issuer: "http://127.0.0.1/oidc" }));
});
return new Promise((resolve) => {
logtoServer.listen(0, "127.0.0.1", () => {
const { port } = logtoServer.address();
logtoUrl = `http://127.0.0.1:${port}/oidc/.well-known/openid-configuration`;
resolve();
});
});
}
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
({ handler } = require("../index.js"));
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address();
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
});
}
function close(target) {
return new Promise((resolve) => {
if (!target) return resolve();
target.close(() => resolve());
});
}
async function get(path, { auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${path}`, { headers });
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
beforeEach(async () => {
await startLogtoStub();
process.env.HEALTH_TOKEN = TOKEN;
process.env.LOGTO_HEALTH_URL = logtoUrl;
await startServer();
});
afterEach(async () => {
await close(server);
await close(logtoServer);
});
describe("metrics module", () => {
test("exposes getCpuPercent, getDisk and collectMetrics", () => {
expect(typeof getCpuPercent).toBe("function");
expect(typeof getDisk).toBe("function");
expect(typeof collectMetrics).toBe("function");
});
test("getDisk returns the four numeric disk fields", () => {
const disk = getDisk();
expect(Object.keys(disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
for (const value of Object.values(disk)) expect(typeof value).toBe("number");
});
test("collectMetrics returns the cpu/memory/disk slice of /health", async () => {
const metrics = await collectMetrics();
expect(Object.keys(metrics)).toEqual(["cpu", "memory", "disk"]);
expect(Object.keys(metrics.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
expect(Object.keys(metrics.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(metrics.cpu.cores).toBeGreaterThan(0);
expect(Array.isArray(metrics.cpu.loadAvg)).toBe(true);
});
});
describe("GET /health", () => {
test("401 on invalid token", async () => {
const { status } = await get("/health", { auth: "Bearer wrong" });
expect(status).toBe(401);
});
test("200 returns the documented payload, field for field", async () => {
const { status, body } = await get("/health");
expect(status).toBe(200);
// Key order is part of the contract consumed by the admin dashboard.
expect(Object.keys(body)).toEqual([
"timestamp",
"hostname",
"uptime",
"cpu",
"memory",
"disk",
"logto",
]);
expect(Object.keys(body.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
expect(Object.keys(body.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(Object.keys(body.disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(body.logto.status).toBe("up");
expect(typeof body.logto.responseTimeMs).toBe("number");
expect(typeof body.uptime).toBe("number");
expect(typeof body.hostname).toBe("string");
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
});
// Latency regression guard. Metrics collection (500ms CPU sample) and the
// Logto check must run concurrently: serializing them would make /health take
// 500ms + the Logto response time. With a 1200ms Logto, concurrent lands at
// ~1200ms while serialized lands at ~1700ms — only the former clears 1.5s.
test("stays under 1.5s with a slow Logto (metrics stay concurrent)", async () => {
logtoDelayMs = 1200;
const start = performance.now();
const { status, body } = await get("/health");
const elapsed = performance.now() - start;
expect(status).toBe(200);
expect(body.logto.status).toBe("up");
expect(elapsed).toBeLessThan(1500);
}, 15000);
});

1078
__tests__/hosts.test.js Normal file

File diff suppressed because it is too large Load diff

177
agent/README.md Normal file
View file

@ -0,0 +1,177 @@
# Workstation agent
Collects one snapshot of the machine it runs on and pushes it to
`POST /hosts/<id>` on the health API. Cron runs it every 5 minutes; the
dashboard reads the result through `GET /hosts`.
Zero dependencies — `node:http` / `node:https` and the `metrics.js` already
shared with the server. Node 22+.
| File | Role |
|------|------|
| `push-metrics.js` | Collect, build the payload, POST once, exit |
| `run-push.sh` | Cron wrapper: load the env file, check the install, exec node |
This directory is **not** part of the server image: the `Dockerfile` copies
`package.json index.js metrics.js` one by one, and a test pins that it stays
that way. The agent ships by copying files onto a workstation, never by deploy.
## What it deliberately does not do
**No queue, no replay.** One run is one attempt. A push that fails is dropped,
logged, and the process exits non-zero — the next beat is five minutes away and
a heartbeat from five minutes ago describes a machine that no longer exists.
Nothing is spooled to disk, so nothing can ever be replayed to make the
dashboard show a past that is no longer true.
**No secret in the logs.** `run-push.sh` is meant to be piped into `logger`, so
everything the agent prints ends up in syslog and journald permanently. The
failure path therefore reports **only** `err.code` and the HTTP status — never
an error object, never the request options, never a response body, and never a
header. That is also why `run-push.sh` must never gain a `set -x`: the shell
would echo the token as it sources the env file.
The cost is real and accepted: a `400` tells you the server rejected the
payload, not why. The reason is in the server's logs, and `--dry-run` (below)
shows you the exact payload that was sent.
## Install on a new workstation
### 1. Freeze a copy
The cron must point at a copy, **never at the git working tree**. The server is
deployed by a manual trigger, so the checked-out repo drifts freely: a branch
checkout in `~/claude-code/vps-health-api` would silently change — or break —
the heartbeat of the workstation, and nothing would say so.
`push-metrics.js` loads `metrics.js` from one directory up, so the copy keeps
the repo layout:
```
mkdir -p ~/.local/share/maximus-host-agent/agent
cp metrics.js ~/.local/share/maximus-host-agent/
cp agent/push-metrics.js agent/run-push.sh ~/.local/share/maximus-host-agent/agent/
chmod +x ~/.local/share/maximus-host-agent/agent/run-push.sh
```
Re-run those three `cp` after any change to the agent — that copy step *is* the
deployment.
### 2. Write the env file
`~/.config/maximus-host-agent.env`, mode `600` (the agent warns on any other
mode). Use `printf`, not a heredoc: a trailing newline or space inside the token
value is the most common install failure.
```
HOSTS_API_URL=https://health.lacompagniemaximus.com
HOSTS_INGEST_TOKEN=<the ingest token>
HOST_ID=thinkpad
# NODE_BIN=/usr/bin/node # only if cron cannot find node (see below)
# HOST_AGENT_TIMEOUT_MS=10000 # push timeout, wall-clock; default 10000
```
```
chmod 600 ~/.config/maximus-host-agent.env
```
`HOSTS_INGEST_TOKEN` is the write-only token: it can push snapshots for an
allowlisted id and nothing else. It is deliberately *not* `HEALTH_TOKEN`, so
that the two can be rotated independently and so a workstation that only pushes
never needs the read token.
Do not read more into that separation than it gives you. On the ThinkPad it
buys no containment at all: that machine already stores `HEALTH_TOKEN` in
cleartext for the `defenseur-auto` cron, so losing the laptop compromises both
tokens. **Rotate them together** — see `secret-rotation-ops.md`.
`HOST_ID` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in the server's
`HOSTS_ALLOWED_IDS`, otherwise the push comes back `403`.
### 3. Dry run
Collect and print the payload without touching the network or needing any
config:
```
node ~/.local/share/maximus-host-agent/agent/push-metrics.js --dry-run
```
Then one real push, by hand, before installing the cron:
```
~/.local/share/maximus-host-agent/agent/run-push.sh
# host-agent: ok id=thinkpad status=204 hostname=thinkpad-x1
```
Confirm the server side (from a machine that holds `HEALTH_TOKEN`):
```
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
https://health.lacompagniemaximus.com/hosts
```
The entry for your id should show `online: true` and a small `ageSeconds`.
### 4. Install the cron
```
crontab -e
```
```
*/5 * * * * $HOME/.local/share/maximus-host-agent/agent/run-push.sh 2>&1 | logger -t host-agent
```
Read what it did:
```
journalctl -t host-agent --since -1h
```
If the line works by hand but produces nothing under cron, it is almost always
`node`: cron's `PATH` is `/usr/bin:/bin`, and a node installed by nvm or under
`/usr/local` is not on it. Set `NODE_BIN` to an absolute path in the env file —
the wrapper says so explicitly rather than failing silently.
## One id per workstation
The server keeps **one file per host id** and the last write wins. Two
workstations configured with the same `HOST_ID` therefore overwrite each other
every five minutes, and nothing anywhere reports an error: the dashboard shows
one host that looks perfectly healthy while its numbers come from whichever
machine pushed last.
The signal to watch for is the `hostname` field in `GET /hosts` **changing
between polls** while the id stays the same — plus CPU and memory that jump
around without pattern. If you see that, two machines are sharing an id.
Give every workstation its own id and add it to `HOSTS_ALLOWED_IDS` on the
server before installing the agent on it.
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | Snapshot accepted (server answered 2xx, normally `204`) |
| 1 | Install problem: env file missing, a variable unset, malformed `HOST_ID`, node not found, `metrics.js` not found next to the agent |
| 2 | The push did not land: network error (`code=…`) or a non-2xx answer (`HTTP …`) |
A `1` needs a human on the workstation. A `2` usually fixes itself on the next
beat; a `2` that repeats for an hour means the API or the link is down.
## Payload
Exactly the `GET /health` body minus `logto` and `timestamp`:
```
{ hostname, uptime,
cpu: { model, cores, loadAvg, usagePercent },
memory: { totalGB, usedGB, freeGB, usagePercent },
disk: { totalGB, usedGB, freeGB, usagePercent } }
```
Sharing `metrics.js` with the server keeps the two in step, but it is not the
guarantee — the frozen copy can lag behind a deployed server. The real contract
is the server's own validation, and `__tests__/agent.test.js` checks the agent's
payload against it (including a real snapshot through the real handler).

309
agent/push-metrics.js Normal file
View file

@ -0,0 +1,309 @@
#!/usr/bin/env node
// Local workstation agent: collect one snapshot and POST it to /hosts/<id>.
// Runs from cron every 5 minutes on the ThinkPad. Zero runtime dependencies —
// node:http / node:https only, same posture as the server it talks to.
//
// Two properties are load-bearing and easy to break by accident.
//
// * NO QUEUE, NO REPLAY. One run = one attempt. A missed beat is replaced by
// the next one five minutes later; a stale heartbeat has no value, and a
// spool file would only ever let the dashboard display a past that is no
// longer true. Every failure exits non-zero and stops there.
// * THE FAILURE PATH NEVER PRINTS A REQUEST OBJECT. run-push.sh pipes this
// process' output into `logger -t host-agent`, so anything written here
// lands in /var/log/syslog and journald permanently. An error object or an
// options bag carrying the Authorization header would persist the ingest
// token on disk, in cleartext, forever. Failures are therefore rebuilt from
// a fixed template plus err.code or the HTTP status — nothing else.
// __tests__/agent.test.js asserts no fragment of the token survives into
// the process output.
const os = require("node:os");
const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const DEFAULT_TIMEOUT_MS = 10000;
// Exit codes are the only signal cron has. 1 means "the install is wrong"
// (fix the env file), 2 means "the push did not land" (network or server).
const EXIT_CONFIG = 1;
const EXIT_PUSH = 2;
// Mirrors HOST_ID_PATTERN in index.js. Checked client-side so a typo in the env
// file fails with a readable message instead of an opaque 404 from the server.
const HOST_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
// --- Payload ----------------------------------------------------------------
function pickUsage(value) {
return {
totalGB: value.totalGB,
usedGB: value.usedGB,
freeGB: value.freeGB,
usagePercent: value.usagePercent,
};
}
// Build the POST /hosts/<id> body: the GET /health payload minus `logto` and
// `timestamp`. Fields are copied one by one rather than spread, so a future
// addition to collectMetrics() cannot silently widen what leaves the
// workstation. `uptime` is floored exactly like getHealth() does.
function buildSnapshot(metrics) {
return {
hostname: os.hostname(),
uptime: Math.floor(os.uptime()),
cpu: {
model: metrics.cpu.model,
cores: metrics.cpu.cores,
loadAvg: metrics.cpu.loadAvg,
usagePercent: metrics.cpu.usagePercent,
},
memory: pickUsage(metrics.memory),
disk: pickUsage(metrics.disk),
};
}
// --- Failure messages -------------------------------------------------------
//
// The ONLY three places a failure string is built. Keep it that way: every
// caller below reports through one of these, none of them accepts an error
// object, and none of them is given the token or the request options.
function configFailure(reason) {
return new Error(`configuration error: ${reason}`);
}
function transportFailure(code) {
return new Error(`push failed: request error (code=${code || "UNKNOWN"})`);
}
function httpFailure(statusCode) {
return new Error(`push failed: HTTP ${statusCode}`);
}
// --- Config -----------------------------------------------------------------
function readConfig(env) {
const apiUrl = (env.HOSTS_API_URL || "").trim();
const token = env.HOSTS_INGEST_TOKEN || "";
const hostId = (env.HOST_ID || "").trim();
if (!apiUrl) return { error: configFailure("HOSTS_API_URL is not set") };
if (!token) return { error: configFailure("HOSTS_INGEST_TOKEN is not set") };
if (!hostId) return { error: configFailure("HOST_ID is not set") };
if (!HOST_ID_RE.test(hostId)) {
return {
error: configFailure("HOST_ID must match ^[a-z0-9][a-z0-9-]{0,31}$"),
};
}
// Shape-check the URL here too, so a broken env file exits 1 ("fix the
// install") rather than 2 ("the network is having a bad day"). postSnapshot
// repeats the check because it is also called directly by the tests.
let target;
try {
target = buildTargetUrl(apiUrl, hostId);
} catch {
return { error: configFailure("HOSTS_API_URL is not a valid URL") };
}
if (target.protocol !== "http:" && target.protocol !== "https:") {
return { error: configFailure("HOSTS_API_URL must be http:// or https://") };
}
const timeoutRaw = parseInt(env.HOST_AGENT_TIMEOUT_MS || "", 10);
const timeoutMs =
Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_TIMEOUT_MS;
return { config: { apiUrl, token, hostId, timeoutMs } };
}
// --- Delivery ---------------------------------------------------------------
function buildTargetUrl(apiUrl, hostId) {
const base = String(apiUrl).replace(/\/+$/, "");
return new URL(`${base}/hosts/${hostId}`);
}
// One attempt, hard-bounded in wall-clock time. Resolves to { statusCode } on
// 2xx, rejects with a template message otherwise. No retry lives here or above.
function postSnapshot({ apiUrl, token, hostId, snapshot, timeoutMs = DEFAULT_TIMEOUT_MS }) {
return new Promise((resolve, reject) => {
let target;
try {
target = buildTargetUrl(apiUrl, hostId);
} catch {
reject(configFailure("HOSTS_API_URL is not a valid URL"));
return;
}
const client =
target.protocol === "https:" ? https : target.protocol === "http:" ? http : null;
if (!client) {
reject(configFailure("HOSTS_API_URL must be http:// or https://"));
return;
}
const body = Buffer.from(JSON.stringify(snapshot), "utf-8");
let settled = false;
let req = null;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(arg);
};
// Wall-clock ceiling over the whole exchange: DNS, connect, TLS, response.
// The `timeout` request option only covers socket inactivity, which can add
// up past the budget on a flaky link. destroy() surfaces as an 'error' with
// the code below, so the timeout reports as ETIMEDOUT and nothing else.
const timer = setTimeout(() => {
if (settled || !req) return;
req.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }));
}, timeoutMs);
try {
req = client.request(
target,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": body.length,
Authorization: `Bearer ${token}`,
},
},
(res) => {
const { statusCode } = res;
// Drain without reading: the response body is never inspected and
// never logged. A 400 says the payload was rejected; the reason is in
// the server's logs, where it can be read without a token nearby.
res.resume();
res.on("end", () => {
if (statusCode >= 200 && statusCode < 300) {
finish(resolve, { statusCode });
} else {
finish(reject, httpFailure(statusCode));
}
});
res.on("error", (err) => finish(reject, transportFailure(err && err.code)));
},
);
} catch (err) {
// request() throws synchronously on an unusable header — in practice a
// token with a trailing newline, the classic env-file mistake. Node's
// ERR_INVALID_CHAR names the header but never quotes its value; it is
// still converted here rather than propagated, so no error object from
// the HTTP stack can ever reach a console call.
if (err && err.code === "ERR_INVALID_CHAR") {
finish(
reject,
configFailure(
"HOSTS_INGEST_TOKEN contains an invalid character (a trailing newline in the env file is the usual cause)",
),
);
} else {
finish(reject, transportFailure(err && err.code));
}
return;
}
// err.code and NOTHING else. err.message can carry the resolved address,
// and the error object dragged into a console call would carry the whole
// request — headers included.
req.on("error", (err) => finish(reject, transportFailure(err && err.code)));
req.end(body);
});
}
// --- Entry point ------------------------------------------------------------
// metrics.js is shared with the server and lives one directory up. The frozen
// install copy must keep that layout (see README) — when it does not, say so in
// one line rather than let cron mail a MODULE_NOT_FOUND stack.
function loadCollectMetrics() {
try {
return require(path.join(__dirname, "..", "metrics.js")).collectMetrics;
} catch (err) {
if (err && err.code === "MODULE_NOT_FOUND") {
throw configFailure(
"metrics.js not found one level above the agent — the install copy must keep the repo layout (see agent/README.md)",
);
}
throw err;
}
}
async function main(argv = process.argv.slice(2), env = process.env) {
// Dry run: collect and print the payload, touch no network, need no config.
// This is what commissioning runs first, before the env file even exists.
const dryRun = argv.includes("--dry-run");
// Config first: the CPU sample below costs 500ms, and there is no point
// spending it to then discover HOST_ID is a typo.
let config = null;
if (!dryRun) {
const result = readConfig(env);
if (result.error) {
console.error(`host-agent: ${result.error.message}`);
return EXIT_CONFIG;
}
config = result.config;
}
let snapshot;
try {
const collectMetrics = loadCollectMetrics();
snapshot = buildSnapshot(await collectMetrics());
} catch (err) {
console.error(`host-agent: ${err.message}`);
return EXIT_CONFIG;
}
if (dryRun) {
console.log(JSON.stringify(snapshot, null, 2));
return 0;
}
try {
const { statusCode } = await postSnapshot({ ...config, snapshot });
console.log(
`host-agent: ok id=${config.hostId} status=${statusCode} hostname=${snapshot.hostname}`,
);
return 0;
} catch (err) {
console.error(`host-agent: ${err.message}`);
return EXIT_PUSH;
}
}
if (require.main === module) {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
// Nothing should reach here — every path above is handled. Print the
// message only, never the error object: a stack rendered by console.error
// is fine, but an object argument is what leaks headers.
console.error(`host-agent: unexpected failure: ${err && err.message}`);
process.exitCode = EXIT_PUSH;
},
);
}
module.exports = {
buildSnapshot,
buildTargetUrl,
postSnapshot,
readConfig,
main,
DEFAULT_TIMEOUT_MS,
EXIT_CONFIG,
EXIT_PUSH,
HOST_ID_RE,
};

70
agent/run-push.sh Executable file
View file

@ -0,0 +1,70 @@
#!/bin/sh
#
# Cron wrapper for push-metrics.js: load the secrets, check the install, hand
# over to node. Every five minutes, forever, unattended.
#
# NEVER add `set -x`. This script is meant to be piped into `logger`, and the
# shell would echo the `. "$ENV_FILE"` expansion — the ingest token — straight
# into /var/log/syslog and journald, where it would stay. Same reason nothing
# below ever echoes a variable that holds a secret: the checks print the NAME of
# what is missing, never its value.
#
# Suggested crontab line (see README.md):
# */5 * * * * $HOME/.local/share/maximus-host-agent/agent/run-push.sh 2>&1 | logger -t host-agent
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
AGENT="$SCRIPT_DIR/push-metrics.js"
# Overridable for testing only; cron uses the default.
ENV_FILE="${HOST_AGENT_ENV_FILE:-$HOME/.config/maximus-host-agent.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "host-agent: env file not found: $ENV_FILE" >&2
exit 1
fi
# A warning, not a refusal: a wrong mode bit is worth shouting about, but it is
# not worth silencing the heartbeat of the whole workstation over.
FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "")
if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then
echo "host-agent: warning: $ENV_FILE is mode $FILE_MODE, expected 600" >&2
fi
# shellcheck source=/dev/null
. "$ENV_FILE"
if [ -z "${HOSTS_API_URL:-}" ]; then
echo "host-agent: HOSTS_API_URL is not set in $ENV_FILE" >&2
exit 1
fi
if [ -z "${HOSTS_INGEST_TOKEN:-}" ]; then
echo "host-agent: HOSTS_INGEST_TOKEN is not set in $ENV_FILE" >&2
exit 1
fi
if [ -z "${HOST_ID:-}" ]; then
echo "host-agent: HOST_ID is not set in $ENV_FILE" >&2
exit 1
fi
if [ ! -f "$AGENT" ]; then
echo "host-agent: agent not found: $AGENT" >&2
exit 1
fi
# cron's PATH is famously short (/usr/bin:/bin), and a node installed through
# nvm or /usr/local lives outside it. Set NODE_BIN in the env file when
# `command -v node` comes up empty under cron but works in a login shell.
NODE_BIN="${NODE_BIN:-node}"
if ! command -v "$NODE_BIN" >/dev/null 2>&1; then
echo "host-agent: node not found (NODE_BIN=$NODE_BIN); set NODE_BIN in $ENV_FILE" >&2
exit 1
fi
# HOST_AGENT_TIMEOUT_MS is optional and read by push-metrics.js. It must be
# exported too, otherwise setting it in the env file silently does nothing and
# the agent always runs on its 10s default.
export HOSTS_API_URL HOSTS_INGEST_TOKEN HOST_ID HOST_AGENT_TIMEOUT_MS
exec "$NODE_BIN" "$AGENT" "$@"

567
index.js
View file

@ -1,9 +1,17 @@
const http = require("node:http");
const os = require("node:os");
const { execSync } = require("node:child_process");
const { readFileSync, readdirSync, existsSync } = require("node:fs");
const {
readFileSync,
readdirSync,
existsSync,
mkdirSync,
writeFileSync,
renameSync,
unlinkSync,
} = require("node:fs");
const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
const { createHash, timingSafeEqual } = require("node:crypto");
const { collectMetrics } = require("./metrics.js");
const PORT = parseInt(process.env.PORT || "3001", 10);
const TOKEN = process.env.HEALTH_TOKEN;
@ -20,6 +28,52 @@ const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
const VALID_SEVERITIES = Object.keys(SEVERITY_RANK);
const VALID_CATEGORIES = ["deps", "secrets", "code", "acces", "infra"];
// --- Workstation snapshots (POST /hosts/<id>, GET /hosts) -------------------
const HOSTS_DIR = process.env.HOSTS_DIR || "/data/hosts";
const HOSTS_INGEST_TOKEN = process.env.HOSTS_INGEST_TOKEN;
const MAX_BODY_BYTES = 4096;
const MAX_STRING_LENGTH = 128;
// Single source of truth for what a host id may look like. The allowlist check
// and the route matcher are both built from it, so they can never drift apart.
//
// This narrow pattern IS the path-traversal control. Verified in Node: URL()
// normalises `/hosts/../../etc/passwd` to `/etc/passwd`, while `..%2f..%2f` and
// `THINKPAD` simply fail the match — every hostile id therefore lands on 404,
// before any handler runs. 403 is reserved for well-formed ids that are not in
// the allowlist. Never widen this to `^/hosts/(.+)$` to make a test pass.
const HOST_ID_PATTERN = "[a-z0-9][a-z0-9-]{0,31}";
const HOST_ID_RE = new RegExp(`^${HOST_ID_PATTERN}$`);
const HOST_ROUTE_RE = new RegExp(`^/hosts/(${HOST_ID_PATTERN})$`);
const staleSecondsRaw = parseInt(process.env.HOSTS_STALE_SECONDS || "", 10);
const HOSTS_STALE_SECONDS =
Number.isFinite(staleSecondsRaw) && staleSecondsRaw > 0 ? staleSecondsRaw : 900;
// Parse HOSTS_ALLOWED_IDS once at startup and drop anything that is not a valid
// host id. Entries feed path.join(HOSTS_DIR, `${id}.json`), so a stray space or
// an entry like `../defenseurs/status` must never survive into a file path.
// Rejections are logged rather than silently swallowed: a typo in the env var
// would otherwise look exactly like a workstation that never checked in.
function parseAllowedIds(raw) {
const allowed = new Set();
for (const entry of String(raw).split(",")) {
const id = entry.trim();
if (!id) continue;
if (!HOST_ID_RE.test(id)) {
console.warn(
`WARNING: HOSTS_ALLOWED_IDS entry rejected (not a valid host id): ${safeLogValue(id)}`,
);
continue;
}
allowed.add(id);
}
return allowed;
}
const HOSTS_ALLOWED_IDS = parseAllowedIds(process.env.HOSTS_ALLOWED_IDS || "thinkpad");
// Severity filter. Asymmetric rule (issue #3):
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
// - threshold "INFO" -> INFO only (explicit opt-in)
@ -38,34 +92,9 @@ if (!TOKEN) {
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
}
function readProcStat() {
try {
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
const parts = line.split(/\s+/).slice(1).map(Number);
const idle = parts[3] + parts[4];
const total = parts.reduce((a, b) => a + b, 0);
return { idle, total };
} catch {
return null;
}
}
async function getCpuPercent() {
const t1 = readProcStat();
if (!t1) return 0;
const { idle: idle1, total: total1 } = t1;
// Sample over 500ms without blocking the event loop, so other async work
// (e.g. the Logto healthcheck) can run concurrently.
await delay(500);
const t2 = readProcStat();
if (!t2) return 0;
const dIdle = t2.idle - idle1;
const dTotal = t2.total - total1;
if (dTotal === 0) return 0;
return Math.round((1 - dIdle / dTotal) * 100);
}
// No startup warning for a missing HOSTS_INGEST_TOKEN: the ingestion path
// fail-closes to 503 and logs the reason on the request that hits it, which is
// the moment an operator actually needs to see it.
async function getLogtoHealth() {
const ac = new AbortController();
@ -85,22 +114,6 @@ async function getLogtoHealth() {
}
}
function getDisk() {
try {
// Alpine df doesn't support --output, use standard POSIX format
const out = execSync("df -k /", { encoding: "utf-8" });
const parts = out.trim().split("\n")[1].trim().split(/\s+/);
// df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted
const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1);
const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1);
const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1);
const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0;
return { totalGB, usedGB, freeGB, usagePercent };
} catch {
return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 };
}
}
// Reproduce the isScanReport guard from defenseurs/src/report.ts. The
// defenseur-auto run report has shape { actions[], skipped[] } with no
// findings[] — it must be filtered out so it never reaches the auto pipeline
@ -207,13 +220,11 @@ function findLatestReportForAgent(agent) {
}
async function getHealth() {
const cpus = os.cpus();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const [cpuUsagePercent, logto] = await Promise.all([
getCpuPercent(),
// Keep the Promise.all: the 500ms CPU sample inside collectMetrics() and the
// up-to-3s Logto check must stay concurrent. Awaiting them one after the
// other would push the p99 of /health to ~3.5s.
const [metrics, logto] = await Promise.all([
collectMetrics(),
getLogtoHealth(),
]);
@ -221,52 +232,231 @@ async function getHealth() {
timestamp: new Date().toISOString(),
hostname: os.hostname(),
uptime: Math.floor(os.uptime()),
cpu: {
model: cpus[0]?.model?.trim() || "unknown",
cores: cpus.length,
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
usagePercent: cpuUsagePercent,
},
memory: {
totalGB: +(totalMem / 1e9).toFixed(1),
usedGB: +(usedMem / 1e9).toFixed(1),
freeGB: +(freeMem / 1e9).toFixed(1),
usagePercent: Math.round((usedMem / totalMem) * 100),
},
disk: getDisk(),
...metrics,
logto,
};
}
async function handler(req, res) {
res.setHeader("Content-Type", "application/json");
// --- Hosts helpers ----------------------------------------------------------
// Parse the URL so /reports/scans can carry a `?date=` query string. The
// placeholder host is required because URL() needs an absolute URL.
const parsedUrl = new URL(req.url, "http://localhost");
const pathname = parsedUrl.pathname;
// Printable-ASCII-only, length-capped rendering for anything attacker-supplied
// that reaches a log line (X-Real-IP, host id, env entries). Keeps a crafted
// value from forging extra log records.
function safeLogValue(value, max = 64) {
return String(value).replace(/[^\x20-\x7e]/g, "?").slice(0, max);
}
const validRoutes = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans"];
if (req.method !== "GET" || !validRoutes.includes(pathname)) {
res.writeHead(404);
res.end(JSON.stringify({ error: "Not found" }));
// Log every 401/403. This is the first publicly writable surface on the API:
// without a trace, a token-guessing campaign would leave nothing behind.
function logRejection(req, status, reason, id) {
const ip = safeLogValue(req.headers["x-real-ip"] || req.socket?.remoteAddress || "unknown");
const method = safeLogValue(req.method, 8);
const url = safeLogValue(req.url, 120);
console.warn(
`[auth] ${status} ${method} ${url} ip=${ip} id=${id ? safeLogValue(id, 32) : "-"} reason=${reason}`,
);
}
// Constant-time bearer comparison for the ingestion path. Both sides are hashed
// first, so the buffers are always 32 bytes: timingSafeEqual never throws on
// mismatched lengths, and the length of the real token cannot leak.
//
// Deliberately scoped to the new write path. Realigning the existing
// HEALTH_TOKEN comparison touches every read route in production and is tracked
// separately (issue #17).
function tokenMatches(provided, expected) {
if (typeof provided !== "string" || typeof expected !== "string") return false;
const a = createHash("sha256").update(provided).digest();
const b = createHash("sha256").update(expected).digest();
return timingSafeEqual(a, b);
}
// Read the request body with a hard 4 KiB ceiling. The counter runs on the
// bytes actually received, never on Content-Length: a client can lie in the
// header, and chunked transfer-encoding omits it altogether.
//
// Resolves to the raw string, or to null when the response has already been
// written (413) or the socket died — in which case the caller must not answer.
function readBody(req, res) {
return new Promise((resolve) => {
const chunks = [];
let size = 0;
let settled = false;
req.on("data", (chunk) => {
if (settled) return;
size += chunk.length;
if (size > MAX_BODY_BYTES) {
settled = true;
req.pause();
res.writeHead(413);
// Destroy only once the 413 has been flushed: req.destroy() tears down
// the socket, so cutting first would leave the client with a network
// error instead of a status code.
res.end(JSON.stringify({ error: "Payload too large" }), () => req.destroy());
resolve(null);
return;
}
chunks.push(chunk);
});
if (!TOKEN) {
res.writeHead(401);
res.end(JSON.stringify({ error: "HEALTH_TOKEN not configured" }));
return;
req.on("end", () => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks).toString("utf-8"));
});
req.on("error", () => {
if (settled) return;
settled = true;
resolve(null);
});
});
}
function cleanString(value) {
if (typeof value !== "string") return null;
return value.slice(0, MAX_STRING_LENGTH);
}
function cleanNumber(value) {
return Number.isFinite(value) ? value : null;
}
// { totalGB, usedGB, freeGB, usagePercent } — the shape shared by memory and disk.
function sanitizeUsage(value, label) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return { error: `${label} must be an object` };
}
const out = {};
for (const key of ["totalGB", "usedGB", "freeGB", "usagePercent"]) {
const num = cleanNumber(value[key]);
if (num === null) return { error: `${label}.${key} must be a finite number` };
out[key] = num;
}
return { value: out };
}
function sanitizeCpu(value) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return { error: "cpu must be an object" };
}
const model = cleanString(value.model);
if (model === null) return { error: "cpu.model must be a string" };
const cores = cleanNumber(value.cores);
if (cores === null) return { error: "cpu.cores must be a finite number" };
const usagePercent = cleanNumber(value.usagePercent);
if (usagePercent === null) return { error: "cpu.usagePercent must be a finite number" };
if (!Array.isArray(value.loadAvg)) return { error: "cpu.loadAvg must be an array" };
const loadAvg = [];
for (const entry of value.loadAvg.slice(0, 3)) {
const num = cleanNumber(entry);
if (num === null) return { error: "cpu.loadAvg must contain finite numbers" };
loadAvg.push(num);
}
return { value: { model, cores, loadAvg, usagePercent } };
}
// Rebuild the persisted object field by field from a whitelist — never store
// the payload verbatim. The file is read back by GET /hosts and ends up in the
// admin dashboard's React tree, so an unknown key (`__proto__` among them) or a
// 4 KiB hostname must never make it that far.
function sanitizeSnapshot(payload) {
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
return { error: "body must be a JSON object" };
}
const auth = req.headers["authorization"];
if (auth !== `Bearer ${TOKEN}`) {
res.writeHead(401);
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
const hostname = cleanString(payload.hostname);
if (hostname === null) return { error: "hostname must be a string" };
const uptime = cleanNumber(payload.uptime);
if (uptime === null) return { error: "uptime must be a finite number" };
const cpu = sanitizeCpu(payload.cpu);
if (cpu.error) return cpu;
const memory = sanitizeUsage(payload.memory, "memory");
if (memory.error) return memory;
const disk = sanitizeUsage(payload.disk, "disk");
if (disk.error) return disk;
return {
snapshot: {
hostname,
uptime,
cpu: cpu.value,
memory: memory.value,
disk: disk.value,
},
};
}
// Atomic write: temp file in the SAME directory, then renameSync. rename is
// atomic within a filesystem, so a concurrent GET /hosts either sees the old
// snapshot or the new one — never a half-written JSON.
function writeHostSnapshot(id, record) {
mkdirSync(HOSTS_DIR, { recursive: true });
const finalPath = path.join(HOSTS_DIR, `${id}.json`);
const tmpPath = path.join(HOSTS_DIR, `.${id}.${process.pid}.${Date.now()}.tmp`);
try {
writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
renameSync(tmpPath, finalPath);
} catch (err) {
try {
unlinkSync(tmpPath);
} catch {
// Best effort — the temp file may never have been created.
}
throw err;
}
}
// Read one persisted snapshot. Anything missing, unparsable, off-shape or
// without a usable server timestamp degrades to null: GET /hosts then reports
// that entry as neverSeen instead of failing the whole response.
//
// The file was written by this service, but it lives on a writable bind-mount:
// it is re-run through the same whitelist on the way out rather than trusted.
function readHostSnapshot(id) {
const file = path.join(HOSTS_DIR, `${id}.json`);
let parsed;
try {
parsed = JSON.parse(readFileSync(file, "utf-8"));
} catch (err) {
if (err.code !== "ENOENT") {
console.error(`[hosts] failed to read snapshot for ${id}:`, err.message);
}
return null;
}
if (pathname === "/defenseurs") {
const result = sanitizeSnapshot(parsed);
if (result.error) {
console.error(`[hosts] snapshot for ${id} rejected on read: ${result.error}`);
return null;
}
const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null;
if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) {
console.error(`[hosts] snapshot for ${id} has no usable receivedAt`);
return null;
}
return { ...result.snapshot, receivedAt };
}
// --- Route handlers ---------------------------------------------------------
async function handleHealth(req, res) {
try {
const data = await getHealth();
res.writeHead(200);
res.end(JSON.stringify(data));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
}
function handleDefenseurs(req, res) {
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
try {
const status = readFileSync(statusPath, "utf-8");
@ -276,13 +466,12 @@ async function handler(req, res) {
res.writeHead(200);
res.end(JSON.stringify({ status: "no_data" }));
}
return;
}
}
if (pathname === "/defenseurs/findings") {
const project = parsedUrl.searchParams.get("project");
const category = parsedUrl.searchParams.get("category");
const severity = parsedUrl.searchParams.get("severity");
function handleFindings(req, res, ctx) {
const project = ctx.parsedUrl.searchParams.get("project");
const category = ctx.parsedUrl.searchParams.get("category");
const severity = ctx.parsedUrl.searchParams.get("severity");
if (!project) {
res.writeHead(400);
@ -338,11 +527,10 @@ async function handler(req, res) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
return;
}
}
if (pathname === "/reports/scans") {
const date = parsedUrl.searchParams.get("date");
function handleReportsScans(req, res, ctx) {
const date = ctx.parsedUrl.searchParams.get("date");
// Regex short-circuit before any filesystem access — blocks path
// traversal (`../../etc/passwd` -> 400) and bogus inputs.
if (!date || !SCAN_DATE_RE.test(date)) {
@ -359,16 +547,183 @@ async function handler(req, res) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
}
// Serve one entry per allowlisted workstation, whether or not it ever reported.
// Keeping the neverSeen entries visible is what turns the commissioning window
// (deployed, no heartbeat yet) into "agent not installed" on the dashboard
// instead of a silently missing card.
function handleHostsList(req, res) {
const now = Date.now();
const hosts = [];
for (const id of [...HOSTS_ALLOWED_IDS].sort()) {
const record = readHostSnapshot(id);
if (!record) {
hosts.push({ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true });
continue;
}
// Freshness is computed server-side from the server-stamped receivedAt —
// the client never gets a say in whether it looks online.
const ageSeconds = Math.max(
0,
Math.floor((now - new Date(record.receivedAt).getTime()) / 1000),
);
hosts.push({
id,
hostname: record.hostname,
uptime: record.uptime,
cpu: record.cpu,
memory: record.memory,
disk: record.disk,
receivedAt: record.receivedAt,
ageSeconds,
online: ageSeconds <= HOSTS_STALE_SECONDS,
});
}
res.writeHead(200);
res.end(JSON.stringify({ staleAfterSeconds: HOSTS_STALE_SECONDS, hosts }));
}
async function handleHostIngest(req, res, ctx) {
const id = ctx.hostId;
// The id already matched HOST_ROUTE_RE, so 403 here means "well-formed but
// not one of ours" — the only case that reaches this branch.
if (!HOSTS_ALLOWED_IDS.has(id)) {
logRejection(req, 403, "host id not in HOSTS_ALLOWED_IDS", id);
res.writeHead(403);
res.end(JSON.stringify({ error: "Forbidden" }));
return;
}
const raw = await readBody(req, res);
if (raw === null) return; // 413 already answered, or the socket is gone.
let payload;
try {
payload = JSON.parse(raw);
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Bad request: body must be valid JSON" }));
return;
}
const result = sanitizeSnapshot(payload);
if (result.error) {
res.writeHead(400);
res.end(JSON.stringify({ error: `Bad request: ${result.error}` }));
return;
}
// receivedAt is stamped by the SERVER on arrival. A client-supplied timestamp
// would let a stale (or hostile) agent claim to be fresher than it is.
const record = { id, ...result.snapshot, receivedAt: new Date().toISOString() };
try {
writeHostSnapshot(id, record);
res.removeHeader("Content-Type");
res.writeHead(204);
res.end();
} catch (err) {
console.error(`[hosts] failed to persist snapshot for ${id}:`, err.message);
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error" }));
}
}
// --- Routing ----------------------------------------------------------------
// Route descriptors. `tokenKind` is the ONLY thing that varies between the read
// routes and the ingestion route: authentication itself happens once, in the
// single gate below. Adding a route here therefore cannot accidentally ship
// unauthenticated — which is exactly what a per-branch check invites.
const ROUTES = [
{ method: "GET", path: "/health", tokenKind: "read", handle: handleHealth },
{ method: "GET", path: "/defenseurs", tokenKind: "read", handle: handleDefenseurs },
{ method: "GET", path: "/defenseurs/findings", tokenKind: "read", handle: handleFindings },
{ method: "GET", path: "/reports/scans", tokenKind: "read", handle: handleReportsScans },
{ method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList },
{ method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest },
];
// Deny by default: no descriptor match -> null -> 404.
function resolveRoute(method, pathname) {
for (const route of ROUTES) {
if (route.method !== method) continue;
if (route.path !== undefined) {
if (route.path === pathname) return { route, hostId: null };
continue;
}
const match = route.pattern.exec(pathname);
if (match) return { route, hostId: match[1] };
}
return null;
}
// THE single authentication checkpoint. Returns null when the caller may
// proceed, otherwise the rejection to write. Only the expected token varies
// per descriptor.
function checkAuth(req, route) {
const isIngest = route.tokenKind === "ingest";
const expected = isIngest ? HOSTS_INGEST_TOKEN : TOKEN;
if (!expected) {
return isIngest
? { status: 503, body: { error: "HOSTS_INGEST_TOKEN not configured" }, reason: "ingest token not configured" }
: { status: 401, body: { error: "HEALTH_TOKEN not configured" }, reason: "health token not configured" };
}
const header = req.headers["authorization"];
const ok = isIngest
? typeof header === "string" &&
header.startsWith("Bearer ") &&
tokenMatches(header.slice("Bearer ".length), expected)
: header === `Bearer ${expected}`;
if (!ok) {
return { status: 401, body: { error: "Unauthorized" }, reason: "missing or invalid bearer token" };
}
return null;
}
async function handler(req, res) {
res.setHeader("Content-Type", "application/json");
// Parse the URL so /reports/scans can carry a `?date=` query string. The
// placeholder host is required because URL() needs an absolute URL.
const parsedUrl = new URL(req.url, "http://localhost");
const pathname = parsedUrl.pathname;
// ORDER IS PART OF THE CONTRACT: routing resolves FIRST, so an unknown path
// or a wrong method answers 404 — not 401 — exactly as before this route
// table existed. Two tests in __tests__/auth.test.js pin that ordering.
const resolved = resolveRoute(req.method, pathname);
if (!resolved) {
res.writeHead(404);
res.end(JSON.stringify({ error: "Not found" }));
return;
}
const rejection = checkAuth(req, resolved.route);
if (rejection) {
logRejection(req, rejection.status, rejection.reason, resolved.hostId);
res.writeHead(rejection.status);
res.end(JSON.stringify(rejection.body));
return;
}
try {
const data = await getHealth();
res.writeHead(200);
res.end(JSON.stringify(data));
await resolved.route.handle(req, res, { parsedUrl, hostId: resolved.hostId });
} catch (err) {
// Safety net: every handler already owns its error paths, this only catches
// what none of them anticipated.
console.error(`[handler] unhandled error on ${safeLogValue(pathname, 120)}:`, err.message);
if (!res.headersSent) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
res.end(JSON.stringify({ error: "Internal error" }));
}
}
}
@ -379,4 +734,12 @@ if (require.main === module) {
});
}
module.exports = { handler, allowedSeverities, findLatestReportForAgent };
module.exports = {
handler,
allowedSeverities,
findLatestReportForAgent,
tokenMatches,
sanitizeSnapshot,
HOST_ID_RE,
HOST_ROUTE_RE,
};

84
metrics.js Normal file
View file

@ -0,0 +1,84 @@
const os = require("node:os");
const { execSync } = require("node:child_process");
const { setTimeout: delay } = require("node:timers/promises");
// Host metrics collection (CPU / memory / disk), extracted from index.js so the
// same code can be reused by the local workstation agent. Pure host probing:
// no HTTP, no config, no side effects — the caller owns the response shape.
function readProcStat() {
try {
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
const parts = line.split(/\s+/).slice(1).map(Number);
const idle = parts[3] + parts[4];
const total = parts.reduce((a, b) => a + b, 0);
return { idle, total };
} catch {
return null;
}
}
async function getCpuPercent() {
const t1 = readProcStat();
if (!t1) return 0;
const { idle: idle1, total: total1 } = t1;
// Sample over 500ms without blocking the event loop, so other async work
// (e.g. the Logto healthcheck) can run concurrently.
await delay(500);
const t2 = readProcStat();
if (!t2) return 0;
const dIdle = t2.idle - idle1;
const dTotal = t2.total - total1;
if (dTotal === 0) return 0;
return Math.round((1 - dIdle / dTotal) * 100);
}
function getDisk() {
try {
// Alpine df doesn't support --output, use standard POSIX format
const out = execSync("df -k /", { encoding: "utf-8" });
const parts = out.trim().split("\n")[1].trim().split(/\s+/);
// df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted
const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1);
const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1);
const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1);
const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0;
return { totalGB, usedGB, freeGB, usagePercent };
} catch {
return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 };
}
}
// Collect CPU, memory and disk into the `{ cpu, memory, disk }` slice of the
// /health payload. Awaiting getCpuPercent() first is deliberate: the 500ms
// sample is the only await here, so a caller running this inside a
// Promise.all() keeps its other work (the Logto check) fully concurrent.
// Everything else is cheap and synchronous.
async function collectMetrics() {
const cpus = os.cpus();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const cpuUsagePercent = await getCpuPercent();
return {
cpu: {
model: cpus[0]?.model?.trim() || "unknown",
cores: cpus.length,
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
usagePercent: cpuUsagePercent,
},
memory: {
totalGB: +(totalMem / 1e9).toFixed(1),
usedGB: +(usedMem / 1e9).toFixed(1),
freeGB: +(freeMem / 1e9).toFixed(1),
usagePercent: Math.round((usedMem / totalMem) * 100),
},
disk: getDisk(),
};
}
module.exports = { getCpuPercent, getDisk, collectMetrics };