Track the PostgreSQL cluster backups (la-compagnie-maximus#220) the same
way as /claude-usage: freshness is decided by the server.
- GET /backups (read token) serves { vps, lenovo }: the status.json the
VPS script writes, rebuilt through a whitelist (closed error codes,
rotation block included), and the last workstation pull report. Each
block gets ageSeconds, stale and staleAfterSeconds from the server
(BACKUPS_STALE_SECONDS, 36 h): VPS on generatedAt, workstation on the
server-stamped receivedAt. A broken file gives a null block plus
vpsError/lenovoError, never a 500; no_data only when both are absent.
- POST /backups/pull: fourth token kind, backups-ingest, on
BACKUPS_INGEST_TOKEN (503 naming it when unset). Strict validation of
{ pulledAt, files, ok, error? }, server receivedAt, atomic write in
mode 600 through writeJsonAtomic(), now shared with /claude-usage.
Answers 204 and 413 like the two other ingest routes (the agent only
checks for a 2xx), where the issue said 200 and 400.
- backups.js joins the Dockerfile COPY line. Tests: backups.test.js
(101 cases), auth/writes pins extended, 6 socket cases in test-curl.sh.
Refs #58
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- __tests__/claude-usage.test.js (119 cases): sanitizer contract and
idempotence, dropped values (unknown fields, negative, past 2^53,
detail outside its regex, status outside the enum, bad dates, model
keys), list bounds, token isolation both ways, 503 naming each variable,
413 at 65 537 bytes and /hosts/<id> still 413 at 4 097, atomic mode-600
write, POST then GET round trip, exact staleness boundary under a frozen
clock, no_data degradation, rejection logs, COPY line.
- writes.test.js: POST /claude-usage may write only in the directory of
CLAUDE_USAGE_PATH, its rejections write nothing, GET /claude-usage joins
the read routes.
- README.md and CLAUDE.md: both routes, the three env vars, the
/data/claude-usage read-write mount owned by uid 1000, the token table.
Resolves#48
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs promised that writes.test.js pins every write. They now say what
it covers: node:fs writes and shell commands, on the paths it exercises.
They also name what keeps the second layer up: the Defenseurs content is
group-writable, and only the owning group (defenseur, gid 1001, not the
gid 1000 of node and ubuntu) keeps the container out, so a chgrp ubuntu
would drop that layer silently.
Fixes#36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Defenseurs bind mounts are `:ro` since 2026-09-30, set as a suffix
on the Coolify destination path; the reports Directory Mount was recreated
as a Volume Mount with a Source Path to take it. CLAUDE.md, README.md and
.env.example now describe the two layers (mount mode and Unix
permissions), the three checks to run after a redeploy, and the Coolify
gotchas met on the way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups from the review of the workstation monitoring stack (#18-#23):
- loadAvg must hold exactly 3 finite numbers (1/5/15-min averages). The
admin card joins them, so a shorter array rendered a partial line and a
longer one was silently truncated; both are now a 400, and a stored
snapshot off that format degrades to neverSeen on read.
- sweepStaleTempFiles() runs at startup, before listen, and removes the
`.<id>.<pid>.<ms>.tmp` files a process killed mid-write leaves behind,
once older than an hour. Exact name format only, regular files only
(lstat), best effort: a missing HOSTS_DIR stays silent, other failures
are logged and never stop the server.
- Document that cleanString keeps control characters on purpose (safety
rests on the consumer escaping at render time), and that frozenRequest
does not freeze `new Date()`, which stamps receivedAt on POST.
Fixes#25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Sergent now persists per-finding state (firstSeen, unverified, 30-day
history) in findings-state.json next to status.json. The admin dashboard
needs it for the drill-down, since it is the only source carrying a
finding's age.
- New read route GET /defenseurs/state, path from
DEFENSEURS_FINDINGS_STATE_PATH (default /data/defenseurs/findings-state.json),
already covered by the /data/defenseurs mount.
- Extract serveJsonFile() so /defenseurs and /defenseurs/state share one
contract: raw content, or 200 {status:"no_data"} on any read error.
- Tests: new state.test.js (byte-identical body, no_data, path isolation,
401 x3, 404 on POST) and the route added to the auth.test.js matrix.
- Docs: README and CLAUDE.md endpoint, config and mount sections; the README
mount list now matches the real /data/defenseurs directory mount.
Fixes#27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
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
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>