Commit graph

18 commits

Author SHA1 Message Date
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
le king fu
39a55fad93 docs: sync CLAUDE.md with real Coolify mounts and manual deploy trigger
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>
2026-07-15 20:47:17 -04:00
17f071b1f7 Merge pull request 'feat(defenseurs): add GET /defenseurs/findings?project=X route' (#9) from issue-3-defenseurs-findings into main 2026-07-16 00:45:09 +00:00
le king fu
e88a044711 feat(defenseurs): add GET /defenseurs/findings?project=X route
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>
2026-05-12 20:56:56 -04:00
ac21fd7f4b Merge pull request 'feat(reports): scan archive/ subdir as fallback to handle post-07:30 UTC window' (#8) from feat/reports-scans-archive-fallback into main 2026-05-11 00:44:25 +00:00
le king fu
2e756557ff feat(reports): scan archive/ subdir as fallback to handle post-07:30 UTC window
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>
2026-05-10 16:53:14 -04:00
09a4ddeb34 Merge PR #6: feat(reports): add GET /reports/scans endpoint for defenseur-auto 2026-05-08 01:04:41 +00:00
le king fu
6eda076a25 feat(reports): add GET /reports/scans endpoint for defenseur-auto
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>
2026-05-07 20:50:29 -04:00
f88f44e347 Merge PR #5: docs: warn HEALTH_TOKEN must be runtime-only on Coolify 2026-05-03 20:12:24 +00:00
le king fu
9510e96231 docs: warn HEALTH_TOKEN must be runtime-only on Coolify
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>
2026-05-03 15:54:07 -04:00
fc3c3a9268 Merge pull request 'feat: add Logto healthcheck to /health endpoint' (#2) from issue-1-logto-healthcheck into main 2026-04-22 01:56:22 +00:00
le king fu
28dd759f98 feat: add Logto healthcheck to /health endpoint
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>
2026-04-21 21:38:20 -04:00
le king fu
9a4c5c7775 Add /defenseurs endpoint to serve security status
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>
2026-03-04 20:13:10 -05:00
le king fu
69fea95320 fix: fail-closed auth when HEALTH_TOKEN is not set
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>
2026-03-02 13:06:16 -05:00
le king fu
0e168d5323 fix: use POSIX df for Alpine compatibility
Alpine's df doesn't support --output flag, use df -k instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:52:51 -05:00
le king fu
d6eb06302c feat: initial vps-health-api service
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>
2026-02-26 20:48:09 -05:00