diff --git a/.env.example b/.env.example index 1dab141..dc4aaa2 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ PORT=3001 # 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 diff --git a/CLAUDE.md b/CLAUDE.md index a3409a7..ed54247 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,7 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif. - `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`) - `GET /defenseurs` — contenu de status.json (rapports defenseurs) +- `GET /reports/scans?date=YYYY-MM-DD` — agrege les rapports `defenseur-_*.json` du jour, format `{ date, count, reports: Report[] }`. Filtre `isScanReport` (exclut `defenseur-auto_*.json`). Date validee par regex (path traversal bloque). Consommateur : `defenseur-auto` workstation cron (remplace le pre-rsync SSH). Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/reports/scans?date=2026-05-07"`. ## Auth @@ -17,7 +18,8 @@ API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif. - Port : `3001` (env `PORT`) - `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com) -- Bind-mount : `/data/defenseurs/status.json` read-only +- `REPORTS_DIR` : dossier lu par `/reports/scans` (default `/data/defenseurs/reports`) +- Bind-mount : `/data/defenseurs/` (status.json + reports/) read-only ## Deploy diff --git a/index.js b/index.js index aa5f09c..0c0a041 100644 --- a/index.js +++ b/index.js @@ -1,15 +1,18 @@ const http = require("node:http"); const os = require("node:os"); const { execSync } = require("node:child_process"); -const { readFileSync } = require("node:fs"); +const { readFileSync, readdirSync, existsSync } = require("node:fs"); +const path = require("node:path"); const { setTimeout: delay } = require("node:timers/promises"); const PORT = parseInt(process.env.PORT || "3001", 10); const TOKEN = process.env.HEALTH_TOKEN; +const REPORTS_DIR = process.env.REPORTS_DIR || "/data/defenseurs/reports"; const LOGTO_HEALTH_URL = process.env.LOGTO_HEALTH_URL || "https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration"; const LOGTO_TIMEOUT_MS = 3000; +const SCAN_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; if (!TOKEN) { console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed)."); @@ -78,6 +81,50 @@ function getDisk() { } } +// Reproduce the isScanReport guard from defenseurs/src/report.ts. The +// defenseur-auto run report has shape { actions[], skipped[] } with no +// findings[] — it must be filtered out so it never reaches the auto pipeline +// (which expects scan-shaped reports only). +function isScanReport(value) { + return ( + typeof value === "object" && + value !== null && + Array.isArray(value.findings) && + typeof value.agent === "string" && + typeof value.timestamp === "string" + ); +} + +// Read all `defenseur-_*.json` files under REPORTS_DIR for the +// given UTC date. The scan reports use an ISO timestamp with `:` and `.` +// rewritten as `-` in the filename (e.g. defenseur-booking_2026-05-06T05-30-11-249Z.json). +// We match `_` then re-confirm via parsed.timestamp.startsWith(date). +function readScanReportsForDate(date) { + const out = []; + if (!existsSync(REPORTS_DIR)) return out; + + const files = readdirSync(REPORTS_DIR).filter( + (f) => f.startsWith("defenseur-") && f.includes(`_${date}`) && f.endsWith(".json"), + ); + + for (const file of files) { + try { + const raw = readFileSync(path.join(REPORTS_DIR, file), "utf-8"); + const parsed = JSON.parse(raw); + if (!isScanReport(parsed)) continue; + if (!parsed.timestamp.startsWith(date)) continue; + out.push(parsed); + } catch (err) { + console.error(`[reports/scans] failed to parse ${file}:`, err.message); + } + } + + // Stable sort by timestamp asc — same convention as readReports() in + // defenseurs/src/report.ts. + out.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + return out; +} + async function getHealth() { const cpus = os.cpus(); const totalMem = os.totalmem(); @@ -113,8 +160,13 @@ async function getHealth() { const server = http.createServer(async (req, res) => { res.setHeader("Content-Type", "application/json"); - const validRoutes = ["/health", "/defenseurs"]; - if (req.method !== "GET" || !validRoutes.includes(req.url)) { + // Parse the URL so /reports/scans can carry a `?date=` query string. The + // placeholder host is required because URL() needs an absolute URL. + const parsedUrl = new URL(req.url, "http://localhost"); + const pathname = parsedUrl.pathname; + + const validRoutes = ["/health", "/defenseurs", "/reports/scans"]; + if (req.method !== "GET" || !validRoutes.includes(pathname)) { res.writeHead(404); res.end(JSON.stringify({ error: "Not found" })); return; @@ -133,7 +185,7 @@ const server = http.createServer(async (req, res) => { return; } - if (req.url === "/defenseurs") { + if (pathname === "/defenseurs") { const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json"; try { const status = readFileSync(statusPath, "utf-8"); @@ -146,6 +198,27 @@ const server = http.createServer(async (req, res) => { return; } + if (pathname === "/reports/scans") { + const date = parsedUrl.searchParams.get("date"); + // Regex short-circuit before any filesystem access — blocks path + // traversal (`../../etc/passwd` -> 400) and bogus inputs. + if (!date || !SCAN_DATE_RE.test(date)) { + res.writeHead(400); + res.end(JSON.stringify({ error: "Bad request: date=YYYY-MM-DD required" })); + return; + } + + try { + const reports = readScanReportsForDate(date); + res.writeHead(200); + res.end(JSON.stringify({ date, count: reports.length, reports })); + } catch (err) { + res.writeHead(500); + res.end(JSON.stringify({ error: "Internal error", message: err.message })); + } + return; + } + try { const data = await getHealth(); res.writeHead(200); diff --git a/test-curl.sh b/test-curl.sh new file mode 100755 index 0000000..0bd1be1 --- /dev/null +++ b/test-curl.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# test-curl.sh — manual smoke test for vps-health-api endpoints. +# +# Spins up the server against a temporary REPORTS_DIR populated with +# scan/run-report fixtures, then runs curl against each endpoint and +# checks status codes + payload shape. No test runner installed — this +# script is the authoritative regression suite for the GET /reports/scans +# endpoint until vitest/jest is added. +# +# Usage : +# bash test-curl.sh +# +# Exit 0 if all 10 cases pass, exit 1 on first failure (fail-fast). + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:3099}" +TOKEN="${TOKEN:-test-token-123}" +TMP_DIR="$(mktemp -d -t vps-health-api.XXXXXX)" +trap 'rm -rf "$TMP_DIR"; kill "$SERVER_PID" 2>/dev/null || true' EXIT + +# Fixtures : +# - 3 scan reports on 2026-05-07 (booking, simpl-liste, maximus) +# - 1 defenseur-auto run report on 2026-05-07 (must be filtered out) +# - 1 booking scan report on 2026-05-06 (must be excluded by date filter) +mkdir -p "$TMP_DIR/reports" + +cat > "$TMP_DIR/reports/defenseur-booking_2026-05-07T05-30-11-249Z.json" <<'JSON' +{ + "agent": "defenseur-booking", + "timestamp": "2026-05-07T05:30:11.249Z", + "project": "la-suite-booking", + "checksRun": 16, + "checksPassed": 14, + "findings": [] +} +JSON + +cat > "$TMP_DIR/reports/defenseur-simpl-liste_2026-05-07T05-32-04-512Z.json" <<'JSON' +{ + "agent": "defenseur-simpl-liste", + "timestamp": "2026-05-07T05:32:04.512Z", + "project": "simpl-liste", + "checksRun": 12, + "checksPassed": 11, + "findings": [] +} +JSON + +cat > "$TMP_DIR/reports/defenseur-maximus_2026-05-07T05-00-12-100Z.json" <<'JSON' +{ + "agent": "defenseur-maximus", + "timestamp": "2026-05-07T05:00:12.100Z", + "project": "la-compagnie-maximus", + "checksRun": 8, + "checksPassed": 8, + "findings": [] +} +JSON + +cat > "$TMP_DIR/reports/defenseur-auto_2026-05-07.json" <<'JSON' +{ + "agent": "defenseur-auto", + "timestamp": "2026-05-07T07:00:00.000Z", + "status": "ok", + "actions": [], + "skipped": [], + "totalCostUsd": 0 +} +JSON + +cat > "$TMP_DIR/reports/defenseur-booking_2026-05-06T05-30-00-000Z.json" <<'JSON' +{ + "agent": "defenseur-booking", + "timestamp": "2026-05-06T05:30:00.000Z", + "project": "la-suite-booking", + "checksRun": 16, + "checksPassed": 16, + "findings": [] +} +JSON + +# Boot the server with the temp REPORTS_DIR. +PORT=3099 \ +HEALTH_TOKEN="$TOKEN" \ +REPORTS_DIR="$TMP_DIR/reports" \ +LOGTO_HEALTH_URL="http://127.0.0.1:1/never" \ +node "$(dirname "$0")/index.js" >/dev/null 2>&1 & +SERVER_PID=$! + +# Wait for the server to be ready. +for _ in {1..50}; do + if curl -s -o /dev/null "$BASE_URL/health" 2>/dev/null; then break; fi + sleep 0.1 +done + +PASS=0 +FAIL=0 +fail() { + echo "FAIL: $1" + FAIL=$((FAIL+1)) +} +pass() { + echo "PASS: $1" + PASS=$((PASS+1)) +} + +# Case 1 : no auth -> 401 +code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL/reports/scans?date=2026-05-07") +[[ "$code" == "401" ]] && pass "no-auth -> 401" || fail "no-auth -> got $code" + +# Case 2 : wrong token -> 401 +code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer wrong" "$BASE_URL/reports/scans?date=2026-05-07") +[[ "$code" == "401" ]] && pass "wrong-token -> 401" || fail "wrong-token -> got $code" + +# Case 3 : valid token + date 2026-05-07 -> 200, count=3, sorted asc, no auto report +body=$(curl -s -H "Authorization: Bearer $TOKEN" \ + "$BASE_URL/reports/scans?date=2026-05-07") +count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});') +agents=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).reports.map(r=>r.agent).join(","));});') +[[ "$count" == "3" ]] && pass "valid date 2026-05-07 -> count=3" || fail "valid date 2026-05-07 -> count=$count" +[[ "$agents" == "defenseur-maximus,defenseur-booking,defenseur-simpl-liste" ]] \ + && pass "sort by timestamp asc + auto filtered" \ + || fail "sort/filter mismatch -> $agents" + +# Case 4 : invalid date format -> 400 +code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans?date=hello") +[[ "$code" == "400" ]] && pass "invalid date -> 400" || fail "invalid date -> got $code" + +# Case 5 : missing date param -> 400 +code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/scans") +[[ "$code" == "400" ]] && pass "missing date -> 400" || fail "missing date -> got $code" + +# Case 6 : valid token + unknown date (no fixtures) -> 200 count=0 +body=$(curl -s -H "Authorization: Bearer $TOKEN" \ + "$BASE_URL/reports/scans?date=2025-01-01") +count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});') +[[ "$count" == "0" ]] && pass "unknown date -> count=0" || fail "unknown date -> count=$count" + +# Case 7 : valid token + date 2026-05-06 -> 200 count=1 +body=$(curl -s -H "Authorization: Bearer $TOKEN" \ + "$BASE_URL/reports/scans?date=2026-05-06") +count=$(echo "$body" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{console.log(JSON.parse(s).count);});') +[[ "$count" == "1" ]] && pass "date 2026-05-06 -> count=1" || fail "date 2026-05-06 -> count=$count" + +# Case 8 : path traversal -> 400 (regex blocks) +code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $TOKEN" \ + "$BASE_URL/reports/scans?date=../../../etc/passwd") +[[ "$code" == "400" ]] && pass "path traversal -> 400" || fail "path traversal -> got $code" + +# Case 9 : POST -> 404 (GET-only) +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: Bearer $TOKEN" \ + "$BASE_URL/reports/scans?date=2026-05-07") +[[ "$code" == "404" ]] && pass "POST -> 404" || fail "POST -> got $code" + +# Case 10 : wrong path -> 404 +code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $TOKEN" "$BASE_URL/reports/nope") +[[ "$code" == "404" ]] && pass "wrong path -> 404" || fail "wrong path -> got $code" + +echo +echo "=== Results: $PASS passed, $FAIL failed ===" +[[ "$FAIL" == "0" ]] || exit 1