vps-health-api/.env.example
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

25 lines
1.5 KiB
Text

PORT=3001
# HEALTH_TOKEN is read at runtime only (process.env at startup).
# On Coolify: MUST be is_runtime=true, is_buildtime=false.
# Buildtime ARG leaks the secret in clear in application_deployment_queues.logs.
HEALTH_TOKEN=change-me-to-a-strong-secret
LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration
# 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