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
Replaces the SSH/rsync canal between Max's workstation cron and the VPS
for fetching defenseur scan reports. The defenseur-auto orchestrator now
pulls reports/defenseur-X_<date>*.json over HTTPS, reusing HEALTH_TOKEN.
The handler mirrors the style of index.js (HTTP native, no framework),
includes the same isScanReport guard as defenseurs/src/report.ts (filters
out defenseur-auto_*.json run reports), and validates the date param
against /^\d{4}-\d{2}-\d{2}$/ to short-circuit path traversal before any
filesystem access.
Validated by test-curl.sh — 11 cases covering auth, validation, date
filter, isScanReport filter, sort order, GET-only and 404 paths.
Spike: ~/claude-code/.spikes/archived/endpoint-reports-sur-vps-health-api-pour/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add inline warning in .env.example and CLAUDE.md Auth section:
HEALTH_TOKEN is read at runtime only — passing it as Coolify build ARG
leaks the secret in clear in application_deployment_queues.logs.
Refs #4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes#1.
- New `logto: {status, responseTimeMs, error?}` field in /health response
- Configurable via LOGTO_HEALTH_URL env (default: auth.lacompagniemaximus.com
OIDC discovery endpoint)
- 3s timeout via AbortController; /health stays HTTP 200 even if Logto is down
- getCpuPercent converted to async (setTimeout-based delay) so the 500ms CPU
sample and the Logto fetch run concurrently via Promise.all; total latency
stays max(500ms, <=3000ms) instead of the sum
- Commit project CLAUDE.md (previously untracked) with the new field documented
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Zero-dependency Node.js health endpoint exposing CPU, RAM, disk and
uptime metrics. Bearer token auth, Docker-ready (node:22-alpine).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>