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.
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
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
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
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
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
Persistent Storages replace the documented (never-existing) per-file
bind-mounts; agents-map.json check path corrected to /data/defenseurs/;
app has no Forgejo Source so post-merge deploy is a manual API trigger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drill-down endpoint exposing detailed findings per project. Resolves the
HTTP gap for the Vercel admin dashboard, which cannot SSH/Tailscale to
the VPS, plus a future portable /analyse-vulnerabilite skill.
- Project -> agent lookup via /data/defenseurs/agents-map.json (Sergent snapshot)
- findLatestReportForAgent scans REPORTS_DIR + REPORTS_DIR/archive (post-07:30 UTC rotation)
- Filters: category exact match, severity threshold inclusive upward
- Asymmetric severity rule: default hides LOW+INFO; ?severity=LOW returns
LOW+MEDIUM+HIGH+CRITICAL but still hides INFO; INFO opt-in via explicit param
- Distinguishes "report present + scan clean" (no status field) from
"no report at all" ({findings:[], status:"no_data"})
- Bootstraps vitest (devDep; runtime stays 0-dep), 14 tests covering auth,
validation, filters, asymmetry, mtime selection, error paths
- Refactor: export handler so tests can spin up ephemeral servers; server.listen
guarded by require.main === module
Closes#3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sergent renameSync() rotates reports/ -> reports/archive/ at 07:30 UTC daily,
so for ~22h per day the only copy of a fresh scan lives in archive/. The
handler now scans both directories and concatenates with top-level priority
on filename collision. archive/ missing is a silent skip.
Tests : 17/17 in test-curl.sh (11 existing + 6 new for archive coverage).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Reads status.json written by the sergent (Escouade Défenseur)
from a Docker volume mount path. Used by the admin dashboard.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reject all requests if HEALTH_TOKEN env var is undefined instead of
allowing unauthenticated access (fail-open → fail-closed).
Co-Authored-By: Claude Sonnet 4.6 <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>