Compare commits

..

No commits in common. "e181a9691c64caf1e82aa3ca205e96b5734b6948" and "39a55fad93414b8e0049724f351710bfe1eb7a77" have entirely different histories.

13 changed files with 200 additions and 3335 deletions

View file

@ -7,19 +7,3 @@ LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-con
# 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
# --- Workstation snapshots (POST /hosts/<id>, GET /hosts) --------------------
# Directory the API WRITES workstation snapshots into, one <id>.json per host.
# Unlike the /data/defenseurs mounts this one must be writable by uid 1000
# (the `node` user the container runs as) or every ingest answers 500.
HOSTS_DIR=/data/hosts
# Comma-separated allowlist of host ids. Each entry must match
# ^[a-z0-9][a-z0-9-]{0,31}$ — anything else is logged and dropped at startup.
HOSTS_ALLOWED_IDS=thinkpad
# Bearer token for POST /hosts/<id>. Separate from HEALTH_TOKEN on purpose: a
# workstation agent should be able to write its own snapshot without gaining
# read access to the Defenseurs reports. Same Coolify rule as HEALTH_TOKEN —
# is_runtime=true, is_buildtime=false. Unset -> POST /hosts/<id> answers 503.
HOSTS_INGEST_TOKEN=change-me-to-a-strong-secret
# Age (seconds) past which GET /hosts reports a host as offline. Default 900.
HOSTS_STALE_SECONDS=900

View file

@ -1,6 +1,6 @@
# VPS Health API
API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
## Endpoints
@ -9,24 +9,11 @@ API sante minimaliste pour le VPS. Node 22 + HTTP natif, 0 dependance runtime.
- `GET /reports/scans?date=YYYY-MM-DD` — agrege les rapports `defenseur-<agent>_<date>*.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"`.
- `GET /defenseurs/findings?project=X` — findings detailles du Defenseur correspondant. Query params : `project=<name>` (obligatoire, lookup via `agents-map.json`), `category=<deps|secrets|code|acces|infra>` (optionnel, exact match), `severity=<CRITICAL|HIGH|MEDIUM|LOW|INFO>` (optionnel, threshold inclusif vers le haut). Sans `severity` -> MEDIUM+HIGH+CRITICAL (cache LOW+INFO). `severity=LOW` -> LOW+MEDIUM+HIGH+CRITICAL (cache toujours INFO, asymetrie volontaire). `severity=INFO` -> INFO uniquement (opt-in explicite). Reponses : 200 `{ agent, project, timestamp, findings[] }` si report present (sans champ `status`) ; 200 `{ findings: [], status: "no_data" }` si pas de report ; 400 sans project ou param invalide ; 404 projet inconnu ; 500 si `agents-map.json` corrompu. Consommateurs : admin dashboard Vercel (drill-down), futur skill `/analyse-vulnerabilite`. Exemple : `curl -H "Authorization: Bearer $TOKEN" "https://health.lacompagniemaximus.com/defenseurs/findings?project=la-suite-booking&severity=HIGH"`.
- `GET /hosts` — dernier snapshot de chaque poste de l'allowlist. Format `{ staleAfterSeconds, hosts: [{ id, hostname, uptime, cpu, memory, disk, receivedAt, ageSeconds, online }] }`. `online = ageSeconds <= HOSTS_STALE_SECONDS` (defaut 900), calcule cote serveur. Un poste jamais vu — ou dont le fichier est illisible — degrade en `{ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true }` sans faire tomber la reponse : c'est ce qui permet a la carte admin d'afficher « agent non installe » pendant la mise en service au lieu de rester muette. Consommateur : dashboard admin Vercel.
- `POST /hosts/<id>`**seule surface d'ecriture de l'API**. Auth par `HOSTS_INGEST_TOKEN` (pas `HEALTH_TOKEN`). Corps = payload de `GET /health` moins `logto` et `timestamp`. Codes : 204 accepte / 400 JSON illisible ou champ manquant / 401 token absent ou faux / 403 `<id>` bien forme mais hors allowlist / 404 `<id>` hors format / 413 corps > 4 Kio (connexion coupee) / 503 `HOSTS_INGEST_TOKEN` non configure. Exemple : `curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" -H "Content-Type: application/json" --data @snapshot.json "https://health.lacompagniemaximus.com/hosts/thinkpad"`.
## Auth
## Routage et auth
Ordre **non negociable**, fige par deux tests `404 (not 401)` dans `__tests__/auth.test.js` :
1. La requete est resolue en descripteur `{ method, path|pattern, tokenKind, handle }` via la table `ROUTES` d'`index.js`. Aucun match -> **404 immediat** (refus par defaut), avant toute verification de token.
2. `checkAuth()`**un seul point de controle**, dont seul le token attendu varie (`tokenKind: "read"` -> `HEALTH_TOKEN`, `"ingest"` -> `HOSTS_INGEST_TOKEN`). Ne jamais disperser le controle dans les branches : c'est ainsi qu'une route finit non protegee.
3. Dispatch vers le handler.
Consequence voulue : un appelant non authentifie sur une route inconnue voit 404, pas 401. Ne pas « corriger » cet ordre en mettant l'auth d'abord.
- Fail-closed : `HEALTH_TOKEN` absent -> 401 sur les lectures ; `HOSTS_INGEST_TOKEN` absent -> 503 sur l'ingestion
- `tokenMatches()` (`timingSafeEqual` sur empreintes SHA-256) protege **uniquement** le chemin d'ingestion. Le realignement du `HEALTH_TOKEN` existant est suivi separement (issue #17) : il toucherait tous les chemins de lecture en production
- 401 passe avant 403 : un appelant sans token ne peut pas sonder l'allowlist
- Chaque rejet 401/403 est journalise (`[auth] <code> <method> <url> ip=<X-Real-IP> id=<host> reason=<motif>`), valeurs filtrees en ASCII imprimable pour empecher la forge de lignes de log
- **Coolify** : `HEALTH_TOKEN` et `HOSTS_INGEST_TOKEN` doivent etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
- Bearer token via env `HEALTH_TOKEN`
- Fail-closed : si `HEALTH_TOKEN` non configure, toutes les requetes sont refusees
- **Coolify** : `HEALTH_TOKEN` doit etre `is_runtime=true, is_buildtime=false`. Buildtime fait fuiter le secret en clair dans `application_deployment_queues.logs`. Voir `la-compagnie-maximus/docs/coolify-ops.md` section "Secrets en buildtime".
## Config
@ -34,14 +21,9 @@ Consequence voulue : un appelant non authentifie sur une route inconnue voit 404
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
- `REPORTS_DIR` : dossier lu par `/reports/scans` et `/defenseurs/findings` (default `/data/defenseurs/reports`)
- `DEFENSEURS_AGENTS_MAP_PATH` : snapshot project->agent ecrit par le Sergent (default `/data/defenseurs/agents-map.json`)
- `HOSTS_DIR` : dossier **ecrit** par `POST /hosts/<id>`, un `<id>.json` par poste (default `/data/hosts`)
- `HOSTS_ALLOWED_IDS` : allowlist separee par virgules (default `thinkpad` — le Pop!_OS est hors scope). Chaque entree est validee au demarrage par la meme regex que la route ; une entree invalide est journalisee et ecartee
- `HOSTS_INGEST_TOKEN` : token d'ingestion, distinct de `HEALTH_TOKEN` (pas de default — absent = 503)
- `HOSTS_STALE_SECONDS` : age au-dela duquel un poste est reporte hors ligne (default 900)
- Montages Coolify (Persistent Storages, UI seulement — aucun endpoint API) :
- `/data/defenseurs` (host) -> `/data/defenseurs` : `status.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne. **Lecture seule** pour cette API.
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10). **Lecture seule** pour cette API.
- `/data/hosts` (host) -> `/data/hosts` : snapshots des postes. **Lecture-ecriture** — doit appartenir a l'uid 1000 (`node`, l'utilisateur du conteneur), sinon chaque ingestion repond 500.
- `/data/defenseurs` (host) -> `/data/defenseurs` : `status.json` + `agents-map.json`, ecrits directement la par le Sergent. `/home/defenseur/defenseurs/status.json` est un leurre obsolete lu par personne.
- `/home/defenseur/defenseurs/reports` (host) -> `/data/defenseurs/reports` : rapports de scan (+ sous-dir `archive/`). Pose le 2026-07-15 (issue #10).
## Deploy
@ -56,24 +38,13 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests
- `npm test` (vitest) — 244 cas
- `__tests__/findings.test.js``/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
- `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence)
- `__tests__/auth.test.js` — filet de securite auth sur les routes de lecture (19 cas : matrice route x mode d'echec, en-tetes malformes, fail-closed, et **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**)
- `__tests__/hosts.test.js` — matrice exhaustive des endpoints postes (155 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture)
- `__tests__/agent.test.js` — agent poste (50 cas : contrat de payload verifie contre `sanitizeSnapshot()`, une seule tentative sans rejeu, timeout, codes de sortie, wrapper shell, et **le token absent de toute sortie d'echec, fragments de 5 caracteres compris**)
- `npm test` (vitest) — couvre `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
- Runtime reste 0-dep ; vitest en devDep uniquement
## Gotchas
- Pas d'Express — HTTP natif Node.js uniquement
- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que les tests locaux ne voient pas. Corollaire : `agent/` n'entre PAS dans l'image — c'est du code de poste, livre par copie de fichiers (voir `agent/README.md`), et un test epingle que le `COPY` ne le mentionne jamais.
- `agent/` tourne sur le ThinkPad, pas sur le VPS, et sa sortie part dans `logger -t host-agent` : tout ce qu'il imprime finit dans `/var/log/syslog` et journald pour de bon. Le chemin d'erreur ne rend donc que `err.code` et le code HTTP — jamais un objet d'erreur, jamais les options de requete, jamais un en-tete. Et jamais de `set -x` dans `run-push.sh` : le shell echoerait le token en sourcant le fichier d'env.
- `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`.
- L'API n'est plus read-only depuis l'issue #13. `status.json`, `agents-map.json` et les rapports de scan restent ecrits par le Sergent defenseurs et lus seulement ici ; en revanche `POST /hosts/<id>` ecrit dans `HOSTS_DIR`. Consequence : ce montage-la doit etre en lecture-ecriture pour l'uid 1000, contrairement aux montages `/data/defenseurs`.
- L'ecriture des snapshots passe par un fichier temporaire dans le **meme** repertoire puis `renameSync``rename` est atomique dans un systeme de fichiers, donc un `GET /hosts` concurrent voit l'ancien snapshot ou le nouveau, jamais un JSON tronque. Ne pas « simplifier » en `writeFileSync` direct.
- L'objet persiste est **reconstruit champ par champ** depuis une liste blanche (nombres via `Number.isFinite`, chaines plafonnees a 128 caracteres) — jamais le payload verbatim. Le fichier est relu par `GET /hosts` et finit dans l'arbre React de l'admin : une cle `__proto__` ou un `hostname` de 4 Kio ne doit jamais arriver la. La relecture repasse par la meme liste blanche, le fichier sur disque n'etant pas plus digne de confiance que le payload.
- Le plafond de corps a 4 Kio compte les octets **recus**, pas le `Content-Length` : un client peut mentir dans l'en-tete, et le transfert chunke l'omet. Au-dela -> 413 puis `req.destroy()` une fois la reponse ecoulee (detruire avant le flush laisserait le client avec une erreur reseau au lieu d'un code).
- Le `status.json` et `agents-map.json` sont ecrits par le Sergent defenseurs, pas par cette API (read-only)
- `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500.
- Coolify ignore silencieusement `-v` dans `custom_docker_run_options` — les volumes passent par les Persistent Storages (UI seulement). Details dans `la-compagnie-maximus/docs/coolify-ops.md`.
- Severity threshold est asymetrique : `?severity=LOW` retourne LOW+MEDIUM+HIGH+CRITICAL mais cache INFO. INFO est seulement accessible via `?severity=INFO` explicite (cache le bruit par defaut).

View file

@ -1,6 +1,6 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json index.js metrics.js ./
COPY package.json index.js ./
EXPOSE 3001
USER node
CMD ["node", "index.js"]

View file

@ -4,68 +4,14 @@ Lightweight health monitoring API for the VPS. Node 22, HTTP-native, zero runtim
## Endpoints
Read endpoints require `Authorization: Bearer $HEALTH_TOKEN`. The single write
endpoint uses its own token, `$HOSTS_INGEST_TOKEN`, so a workstation agent can
push its snapshot without gaining read access to the Defenseurs reports.
All endpoints require `Authorization: Bearer $HEALTH_TOKEN`.
| Method | Path | Token | Description |
|--------|------|-------|-------------|
| GET | `/health` | `HEALTH_TOKEN` | CPU, memory, disk, uptime, Logto reachability |
| GET | `/defenseurs` | `HEALTH_TOKEN` | Defenseurs executive status (status.json) |
| GET | `/defenseurs/findings?project=X` | `HEALTH_TOKEN` | Detailed findings for a project's Defenseur |
| GET | `/reports/scans?date=YYYY-MM-DD` | `HEALTH_TOKEN` | Aggregated scan reports for a UTC date |
| GET | `/hosts` | `HEALTH_TOKEN` | Latest snapshot of every allowlisted workstation |
| POST | `/hosts/<id>` | `HOSTS_INGEST_TOKEN` | Ingest one workstation snapshot |
Routing resolves before authentication: an unknown path or a wrong method
answers `404`, never `401`.
### `POST /hosts/<id>`
Body: the `GET /health` payload minus `logto` and `timestamp``hostname`,
`uptime`, `cpu{model,cores,loadAvg,usagePercent}`,
`memory{totalGB,usedGB,freeGB,usagePercent}`, `disk{…same four…}`.
`<id>` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in
`HOSTS_ALLOWED_IDS`. That regex is the path-traversal control — anything that
fails it never reaches a handler.
| Code | Case |
|------|------|
| 204 | Snapshot accepted and written |
| 400 | Unparsable JSON, missing field, or wrong type |
| 401 | Ingest token missing or wrong |
| 403 | `<id>` well-formed but not in the allowlist |
| 404 | `<id>` outside the format (never reaches the handler) |
| 413 | Body past 4 KiB — connection cut |
| 503 | `HOSTS_INGEST_TOKEN` not configured (fail-closed) |
The stored object is rebuilt field by field from a whitelist (numbers must be
finite, strings are capped at 128 chars, everything else dropped) and written
atomically via a temp file + `rename`. `receivedAt` is stamped by the server on
arrival; a client-supplied timestamp is ignored.
Example:
```
curl -X POST -H "Authorization: Bearer $HOSTS_INGEST_TOKEN" \
-H "Content-Type: application/json" --data @snapshot.json \
"https://health.lacompagniemaximus.com/hosts/thinkpad"
```
### `GET /hosts`
```
{ "staleAfterSeconds": 900,
"hosts": [ { "id", "hostname", "uptime", "cpu", "memory", "disk",
"receivedAt", "ageSeconds", "online" } ] }
```
One entry per allowlisted id. `online = ageSeconds <= staleAfterSeconds`,
computed server-side. A host that never checked in — or whose snapshot file is
unreadable — degrades to `{ id, receivedAt: null, ageSeconds: null, online:
false, neverSeen: true }` instead of failing the response, so the dashboard can
say "agent not installed" during commissioning.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/health` | CPU, memory, disk, uptime, Logto reachability |
| GET | `/defenseurs` | Defenseurs executive status (status.json) |
| GET | `/defenseurs/findings?project=X` | Detailed findings for a project's Defenseur |
| GET | `/reports/scans?date=YYYY-MM-DD` | Aggregated scan reports for a UTC date |
### `GET /defenseurs/findings`
@ -99,35 +45,17 @@ curl -H "Authorization: Bearer $HEALTH_TOKEN" \
| Env var | Default | Purpose |
|---------|---------|---------|
| `PORT` | `3001` | HTTP port |
| `HEALTH_TOKEN` | — | Bearer token for the read routes (fail-closed if missing) |
| `HEALTH_TOKEN` | — | Bearer token (fail-closed if missing) |
| `REPORTS_DIR` | `/data/defenseurs/reports` | Scan reports dir |
| `DEFENSEURS_AGENTS_MAP_PATH` | `/data/defenseurs/agents-map.json` | Project -> agent snapshot |
| `LOGTO_HEALTH_URL` | auth.lacompagniemaximus.com | Logto OIDC discovery URL |
| `HOSTS_DIR` | `/data/hosts` | Where workstation snapshots are **written** |
| `HOSTS_ALLOWED_IDS` | `thinkpad` | Comma-separated allowlist of host ids |
| `HOSTS_INGEST_TOKEN` | — | Bearer token for `POST /hosts/<id>` (503 if missing) |
| `HOSTS_STALE_SECONDS` | `900` | Age past which a host is reported offline |
## Bind-mounts (Coolify)
Read-only for the API — written by the Defenseurs Sergent:
## Bind-mounts (Coolify, read-only)
- `/home/defenseur/defenseurs/status.json` -> `/data/defenseurs/status.json`
- `/home/defenseur/defenseurs/reports/` -> `/data/defenseurs/reports/`
- `/home/defenseur/defenseurs/agents-map.json` -> `/data/defenseurs/agents-map.json`
Read-write — the API is the writer:
- `<host>/data/hosts/` -> `/data/hosts/` (`HOSTS_DIR`). Must be writable by uid
1000, the `node` user the container runs as, otherwise every ingest 500s.
## Workstation agent
`agent/` holds the zero-dependency collector that runs from cron on a
workstation and feeds `POST /hosts/<id>`. It is not part of the server image —
it is installed by copying files onto the machine it measures. Install steps,
crontab line and failure modes: [`agent/README.md`](agent/README.md).
## Tests
```

View file

@ -1,597 +0,0 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const {
buildSnapshot,
buildTargetUrl,
postSnapshot,
readConfig,
DEFAULT_TIMEOUT_MS,
EXIT_CONFIG,
EXIT_PUSH,
} = require("../agent/push-metrics.js");
const { collectMetrics } = require("../metrics.js");
const { sanitizeSnapshot } = require("../index.js");
// Coverage for the local workstation agent (issue #15).
//
// Three things are worth stating up front, because they explain why this file
// spends most of its length on failure paths rather than on the happy one.
//
// * THE TOKEN MUST NOT SURVIVE INTO A LOG LINE. run-push.sh pipes the agent's
// output into `logger -t host-agent`; anything printed lands in syslog and
// journald for good. Every failure path here is therefore checked against
// every 5-character-or-longer fragment of the token — message, stack, JSON
// rendering — and the two spawned-process cases check the real stdout and
// stderr of a real run, which is what `logger` would actually swallow.
// * NO QUEUE, NO REPLAY. A failed push is dropped, not retried: the
// assertions count the requests the server saw and expect exactly one.
// * The payload contract is pinned against the server's own
// sanitizeSnapshot(), and one case pushes a REAL collectMetrics() snapshot
// through the REAL ingestion handler. A drift between agent and server
// shows up as a red test rather than as a 400 at 3 a.m. on the ThinkPad.
const AGENT_PATH = path.join(__dirname, "..", "agent", "push-metrics.js");
const RUNNER_PATH = path.join(__dirname, "..", "agent", "run-push.sh");
// Mixed letters and digits, no dictionary word and no run of five digits, so a
// port number or a timestamp in the output can never look like a leak.
const TOKEN = "7f2a9c4e1b6d8035a5e0c3d1";
const HOST_ID = "thinkpad";
const servers = [];
let tmpDir;
function fakeMetrics() {
return {
cpu: {
model: "Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz",
cores: 8,
loadAvg: [0.42, 0.31, 0.25],
usagePercent: 12,
},
memory: { totalGB: 32, usedGB: 12.5, freeGB: 19.5, usagePercent: 39 },
disk: { totalGB: 500, usedGB: 220, freeGB: 280, usagePercent: 44 },
};
}
async function startServer(onRequest) {
const server = http.createServer(onRequest);
servers.push(server);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return { server, url: `http://127.0.0.1:${server.address().port}` };
}
// A recording server that answers with the given status. Returns the log of
// what it saw, so "exactly one request" and "the Authorization header was sent"
// are assertions rather than assumptions.
async function startRecordingServer(status = 204) {
const seen = [];
const { url, server } = await startServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
seen.push({
method: req.method,
url: req.url,
headers: req.headers,
body: Buffer.concat(chunks).toString("utf-8"),
});
res.writeHead(status);
res.end();
});
});
return { url, server, seen };
}
async function closeServers() {
while (servers.length) {
const server = servers.pop();
server.closeAllConnections?.();
await new Promise((resolve) => server.close(resolve));
}
}
// Every substring of the token from `min` characters up. A failure message that
// contains none of them cannot contain the token, nor a truncated half of it.
function tokenFragments(token, min = 5) {
const out = [];
for (let len = min; len <= token.length; len++) {
for (let i = 0; i + len <= token.length; i++) out.push(token.slice(i, i + len));
}
return out;
}
function expectNoTokenLeak(text, token = TOKEN) {
const haystack = String(text);
const leaked = tokenFragments(token).filter((frag) => haystack.includes(frag));
// Report the longest match only: every shorter fragment of it also matches,
// and dumping the 200-odd of them buries the failure it is reporting.
const worst = leaked.reduce((a, b) => (b.length > a.length ? b : a), "");
expect(
leaked.length,
`token leaked into the output — longest fragment found: "${worst}"`,
).toBe(0);
}
// Everything a careless `console.error(err)` would print.
function renderError(err) {
return [
err.message,
String(err),
err.stack,
JSON.stringify(err),
JSON.stringify(err, Object.getOwnPropertyNames(err)),
].join("\n");
}
async function pushTo(url, overrides = {}) {
return postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 2000,
...overrides,
});
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-test-"));
});
afterEach(async () => {
await closeServers();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("payload — matches the POST /hosts/<id> contract", () => {
test("the built snapshot passes the server's sanitizeSnapshot() unchanged", () => {
const snapshot = buildSnapshot(fakeMetrics());
const result = sanitizeSnapshot(snapshot);
expect(result.error).toBeUndefined();
expect(result.snapshot).toEqual(snapshot);
});
test("exactly the documented keys, nothing more", () => {
const snapshot = buildSnapshot(fakeMetrics());
expect(Object.keys(snapshot).sort()).toEqual([
"cpu",
"disk",
"hostname",
"memory",
"uptime",
]);
expect(Object.keys(snapshot.cpu).sort()).toEqual([
"cores",
"loadAvg",
"model",
"usagePercent",
]);
for (const key of ["memory", "disk"]) {
expect(Object.keys(snapshot[key]).sort()).toEqual([
"freeGB",
"totalGB",
"usagePercent",
"usedGB",
]);
}
});
test("an extra field added to collectMetrics() does not leave the workstation", () => {
const metrics = fakeMetrics();
metrics.temperatureC = 61;
metrics.cpu.serial = "PF0X1234";
const snapshot = buildSnapshot(metrics);
expect(snapshot.temperatureC).toBeUndefined();
expect(snapshot.cpu.serial).toBeUndefined();
});
test("hostname and uptime come from the host, uptime floored like getHealth()", () => {
const snapshot = buildSnapshot(fakeMetrics());
expect(snapshot.hostname).toBe(os.hostname());
expect(Number.isInteger(snapshot.uptime)).toBe(true);
expect(snapshot.uptime).toBeGreaterThan(0);
});
test("a real collectMetrics() snapshot is accepted by sanitizeSnapshot()", async () => {
const snapshot = buildSnapshot(await collectMetrics());
const result = sanitizeSnapshot(snapshot);
expect(result.error).toBeUndefined();
expect(result.snapshot).toEqual(snapshot);
});
});
describe("delivery — one attempt, no queue", () => {
test("POSTs to /hosts/<id> with the bearer token and resolves on 204", async () => {
const { url, seen } = await startRecordingServer(204);
// Build ONCE and compare against that object. Two calls to buildSnapshot()
// straddling a second boundary differ by 1 on `uptime`, which turns this
// assertion into a one-in-several-runs failure.
const snapshot = buildSnapshot(fakeMetrics());
const result = await pushTo(url, { snapshot });
expect(result.statusCode).toBe(204);
expect(seen).toHaveLength(1);
expect(seen[0].method).toBe("POST");
expect(seen[0].url).toBe(`/hosts/${HOST_ID}`);
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(seen[0].headers["content-type"]).toBe("application/json");
expect(JSON.parse(seen[0].body)).toEqual(snapshot);
});
test("any 2xx counts as delivered", async () => {
const { url } = await startRecordingServer(200);
await expect(pushTo(url)).resolves.toEqual({ statusCode: 200 });
});
test.each([400, 401, 403, 413, 500, 503])("rejects on HTTP %i", async (status) => {
const { url } = await startRecordingServer(status);
await expect(pushTo(url)).rejects.toThrow(`push failed: HTTP ${status}`);
});
test("a rejected push is dropped, never retried", async () => {
const { url, seen } = await startRecordingServer(500);
await expect(pushTo(url)).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 150));
expect(seen).toHaveLength(1);
});
test("reports only err.code when the connection is refused", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
await expect(pushTo(url)).rejects.toThrow("push failed: request error (code=ECONNREFUSED)");
});
test("gives up on a server that never answers, as ETIMEDOUT", async () => {
const { url } = await startServer(() => {
/* never responds */
});
const started = Date.now();
await expect(pushTo(url, { timeoutMs: 200 })).rejects.toThrow(
"push failed: request error (code=ETIMEDOUT)",
);
expect(Date.now() - started).toBeLessThan(2000);
});
test("the default timeout is 10s", () => {
expect(DEFAULT_TIMEOUT_MS).toBe(10000);
});
test("a trailing slash on HOSTS_API_URL does not double up", () => {
expect(buildTargetUrl("https://health.example.com/", "thinkpad").href).toBe(
"https://health.example.com/hosts/thinkpad",
);
expect(buildTargetUrl("https://health.example.com", "thinkpad").href).toBe(
"https://health.example.com/hosts/thinkpad",
);
});
test.each([
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
["not a url", "HOSTS_API_URL is not a valid URL"],
])("refuses %s before opening a socket", async (apiUrl, message) => {
await expect(pushTo(apiUrl)).rejects.toThrow(message);
});
});
describe("the failure path never carries the token", () => {
test("HTTP failure: message, stack and JSON rendering are all clean", async () => {
const { url } = await startRecordingServer(500);
const err = await pushTo(url).catch((e) => e);
expect(err.message).toBe("push failed: HTTP 500");
expectNoTokenLeak(renderError(err));
});
test("transport failure: message, stack and JSON rendering are all clean", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
const err = await pushTo(url).catch((e) => e);
expectNoTokenLeak(renderError(err));
});
test("timeout failure: message, stack and JSON rendering are all clean", async () => {
const { url } = await startServer(() => {});
const err = await pushTo(url, { timeoutMs: 200 }).catch((e) => e);
expectNoTokenLeak(renderError(err));
});
test("a token with a trailing newline fails without quoting itself", async () => {
const { url } = await startRecordingServer(204);
const err = await pushTo(url, { token: `${TOKEN}\n` }).catch((e) => e);
expect(err.message).toContain("HOSTS_INGEST_TOKEN contains an invalid character");
expectNoTokenLeak(renderError(err));
});
test("the real process output of a failing run contains no fragment of the token", async () => {
const { url } = await startRecordingServer(500);
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
env: {
PATH: process.env.PATH,
HOSTS_API_URL: url,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
},
}).catch((e) => e);
expect(result.code).toBe(EXIT_PUSH);
expect(result.stderr).toContain("host-agent: push failed: HTTP 500");
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("the real process output of an unreachable server contains no fragment of the token", async () => {
const { url, server } = await startRecordingServer();
await new Promise((resolve) => server.close(resolve));
servers.pop();
const result = await execFileAsync(process.execPath, [AGENT_PATH], {
env: {
PATH: process.env.PATH,
HOSTS_API_URL: url,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
},
}).catch((e) => e);
expect(result.code).toBe(EXIT_PUSH);
expect(result.stderr).toContain("code=ECONNREFUSED");
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
});
describe("configuration", () => {
test.each([
["HOSTS_API_URL", "HOSTS_API_URL is not set"],
["HOSTS_INGEST_TOKEN", "HOSTS_INGEST_TOKEN is not set"],
["HOST_ID", "HOST_ID is not set"],
])("a missing %s is named, not guessed", (missing, message) => {
const env = { HOSTS_API_URL: "https://x.test", HOSTS_INGEST_TOKEN: TOKEN, HOST_ID };
delete env[missing];
const { error, config } = readConfig(env);
expect(config).toBeUndefined();
expect(error.message).toContain(message);
expectNoTokenLeak(renderError(error));
});
test.each(["THINKPAD", "-thinkpad", "think pad", "a".repeat(33), "../thinkpad"])(
"rejects the malformed HOST_ID %j client-side",
(hostId) => {
const { error } = readConfig({
HOSTS_API_URL: "https://x.test",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID: hostId,
});
expect(error.message).toContain("HOST_ID must match");
},
);
test.each([
["ftp://health.example.com", "HOSTS_API_URL must be http:// or https://"],
["not a url", "HOSTS_API_URL is not a valid URL"],
])("a broken HOSTS_API_URL (%j) is an install error, not a push error", (apiUrl, message) => {
const { error, config } = readConfig({
HOSTS_API_URL: apiUrl,
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
});
expect(config).toBeUndefined();
expect(error.message).toContain(message);
});
test("accepts a well-formed config and defaults the timeout", () => {
const { config } = readConfig({
HOSTS_API_URL: "https://health.example.com",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
});
expect(config).toEqual({
apiUrl: "https://health.example.com",
token: TOKEN,
hostId: HOST_ID,
timeoutMs: DEFAULT_TIMEOUT_MS,
});
});
test("--dry-run prints the payload and touches no network", async () => {
const { stdout } = await execFileAsync(process.execPath, [AGENT_PATH, "--dry-run"], {
env: { PATH: process.env.PATH },
});
const snapshot = JSON.parse(stdout);
expect(sanitizeSnapshot(snapshot).error).toBeUndefined();
expect(snapshot.hostname).toBe(os.hostname());
});
});
describe("run-push.sh", () => {
function writeEnvFile(lines, mode = 0o600) {
const file = path.join(tmpDir, "maximus-host-agent.env");
fs.writeFileSync(file, `${lines.join("\n")}\n`, { mode });
return file;
}
async function runWrapper(envFile) {
return execFileAsync(RUNNER_PATH, [], {
env: { PATH: process.env.PATH, HOME: tmpDir, HOST_AGENT_ENV_FILE: envFile },
}).catch((e) => e);
}
test("delivers the snapshot end to end", async () => {
const { url, seen } = await startRecordingServer(204);
const envFile = writeEnvFile([
`HOSTS_API_URL=${url}`,
`HOSTS_INGEST_TOKEN=${TOKEN}`,
`HOST_ID=${HOST_ID}`,
`NODE_BIN=${process.execPath}`,
]);
const result = await runWrapper(envFile);
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`host-agent: ok id=${HOST_ID} status=204`);
expect(seen).toHaveLength(1);
expect(seen[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
expect(sanitizeSnapshot(JSON.parse(seen[0].body)).error).toBeUndefined();
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test.each([
[["HOSTS_INGEST_TOKEN", "HOST_ID"], "HOSTS_API_URL is not set"],
[["HOSTS_API_URL", "HOST_ID"], "HOSTS_INGEST_TOKEN is not set"],
[["HOSTS_API_URL", "HOSTS_INGEST_TOKEN"], "HOST_ID is not set"],
])("stops when a variable is missing (%j)", async (present, message) => {
const values = {
HOSTS_API_URL: "https://health.example.com",
HOSTS_INGEST_TOKEN: TOKEN,
HOST_ID,
};
const envFile = writeEnvFile(present.map((key) => `${key}=${values[key]}`));
const result = await runWrapper(envFile);
expect(result.code).toBe(1);
expect(result.stderr).toContain(message);
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("stops when the env file is absent", async () => {
const missing = path.join(tmpDir, "nope.env");
const result = await runWrapper(missing);
expect(result.code).toBe(1);
expect(result.stderr).toContain(`host-agent: env file not found: ${missing}`);
});
test("warns about a world-readable env file but still runs", async () => {
const { url, seen } = await startRecordingServer(204);
const envFile = writeEnvFile(
[
`HOSTS_API_URL=${url}`,
`HOSTS_INGEST_TOKEN=${TOKEN}`,
`HOST_ID=${HOST_ID}`,
`NODE_BIN=${process.execPath}`,
],
0o644,
);
const result = await runWrapper(envFile);
expect(result.stderr).toContain("is mode 644, expected 600");
expect(seen).toHaveLength(1);
expectNoTokenLeak(`${result.stdout}${result.stderr}`);
});
test("never enables shell tracing — `set -x` would echo the token into syslog", () => {
const script = fs.readFileSync(RUNNER_PATH, "utf-8");
const traced = script
.split("\n")
.filter((line) => !line.trim().startsWith("#"))
.filter((line) => /\bset\b[^#\n]*-[a-z]*x/.test(line));
expect(traced).toEqual([]);
});
test("is executable", () => {
expect(fs.statSync(RUNNER_PATH).mode & 0o111).toBeGreaterThan(0);
});
});
describe("the agent stays out of the server image", () => {
test("the Dockerfile copies files one by one and none of them is the agent", () => {
const dockerfile = fs.readFileSync(path.join(__dirname, "..", "Dockerfile"), "utf-8");
const copies = dockerfile
.split("\n")
.filter((line) => /^\s*(COPY|ADD)\b/i.test(line));
expect(copies).toHaveLength(1);
expect(copies[0]).not.toMatch(/agent/);
// A bare `COPY . .` would drag agent/ in without ever naming it.
expect(copies[0]).not.toMatch(/^\s*COPY\s+\.\s/i);
});
});
describe("against the real ingestion handler", () => {
const savedEnv = {};
const ENV_KEYS = ["HOSTS_INGEST_TOKEN", "HOSTS_DIR", "HOSTS_ALLOWED_IDS", "HEALTH_TOKEN"];
beforeEach(() => {
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
process.env.HOSTS_INGEST_TOKEN = TOKEN;
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = HOST_ID;
process.env.HEALTH_TOKEN = "read-token";
});
afterEach(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
});
test("a real snapshot is accepted with 204 and persisted field for field", async () => {
// Fresh import so index.js picks up the env above.
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
const snapshot = buildSnapshot(await collectMetrics());
const result = await postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot,
timeoutMs: 5000,
});
expect(result.statusCode).toBe(204);
const written = JSON.parse(
fs.readFileSync(path.join(process.env.HOSTS_DIR, `${HOST_ID}.json`), "utf-8"),
);
expect(written.id).toBe(HOST_ID);
expect(typeof written.receivedAt).toBe("string");
delete written.id;
delete written.receivedAt;
expect(written).toEqual(snapshot);
});
test("a wrong ingest token is rejected with 401 and no fragment leaks", async () => {
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
const err = await postSnapshot({
apiUrl: url,
token: "wrong-token",
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 5000,
}).catch((e) => e);
expect(err.message).toBe("push failed: HTTP 401");
expectNoTokenLeak(renderError(err));
});
test("a host id outside the allowlist surfaces as HTTP 403", async () => {
process.env.HOSTS_ALLOWED_IDS = "someone-else";
delete require.cache[require.resolve("../index.js")];
const { handler } = require("../index.js");
const { url } = await startServer(handler);
await expect(
postSnapshot({
apiUrl: url,
token: TOKEN,
hostId: HOST_ID,
snapshot: buildSnapshot(fakeMetrics()),
timeoutMs: 5000,
}),
).rejects.toThrow("push failed: HTTP 403");
});
});

View file

@ -1,201 +0,0 @@
const http = require("node:http");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
// Authentication safety net for the four read routes.
//
// findings.test.js and health.test.js cover what each route *returns*; this
// file covers the gate in front of all of them. The small overlap on
// /defenseurs/findings and /health is deliberate: the value of this file is the
// complete route x failure-mode matrix in one place, so a routing/auth refactor
// that only rewires part of the table still fails loudly here.
//
// These tests describe the CURRENT behaviour of index.js. If one of them turns
// red after a refactor, the refactor changed the security contract.
const TOKEN = "test-token";
// Every GET route reachable by the handler. Any new route must be added here.
// POST /hosts/<id> is not a GET, so it gets its own describe block below —
// but it is covered, and any future route must be too.
const ROUTES = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans", "/hosts"];
// Ingest token, distinct from the read token on purpose: a read token must not
// open the write path, and a write token must not open the read routes.
const INGEST_TOKEN = "test-ingest-token";
let tmpDir;
let server;
let baseUrl;
let handler;
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
({ handler } = require("../index.js"));
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address();
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
});
}
function stopServer() {
return new Promise((resolve) => {
if (!server) return resolve();
server.close(() => resolve());
});
}
async function restartServer() {
await stopServer();
await startServer();
}
async function request(path, { method = "GET", auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${path}`, { method, headers });
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vps-health-auth-test-"));
process.env.HEALTH_TOKEN = TOKEN;
// Point every filesystem read at the temp dir so a rejected request can never
// fall back to the host's real /data/defenseurs.
process.env.REPORTS_DIR = path.join(tmpDir, "reports");
process.env.DEFENSEURS_AGENTS_MAP_PATH = path.join(tmpDir, "agents-map.json");
process.env.DEFENSEURS_STATUS_PATH = path.join(tmpDir, "status.json");
process.env.HOSTS_DIR = path.join(tmpDir, "hosts");
process.env.HOSTS_ALLOWED_IDS = "thinkpad";
// Configured on purpose: an unset ingest token answers 503 before the header
// is ever read, which would hide the 401 these tests are here to pin.
process.env.HOSTS_INGEST_TOKEN = INGEST_TOKEN;
// Closed port: /health must never reach the real IdP. If the auth gate ever
// fails open, the request errors out fast instead of hitting production.
process.env.LOGTO_HEALTH_URL = "http://127.0.0.1:1/oidc/.well-known/openid-configuration";
await startServer();
});
afterEach(async () => {
await stopServer();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("auth gate — missing Authorization header", () => {
test.each(ROUTES)("401 on GET %s with no Authorization header", async (route) => {
const { status, body } = await request(route, { auth: null });
expect(status).toBe(401);
// Pin the gate itself: auth runs before any query-param validation, so
// /defenseurs/findings and /reports/scans must answer 401, never 400.
expect(body.error).toBe("Unauthorized");
});
});
describe("auth gate — invalid token", () => {
test.each(ROUTES)("401 on GET %s with a wrong bearer token", async (route) => {
const { status, body } = await request(route, { auth: "Bearer wrong-token" });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
});
describe("auth gate — malformed Authorization header", () => {
// The header is compared byte for byte against `Bearer <token>`, so these
// near-misses are all rejected. /defenseurs stands in for the four routes:
// it serves the parc-wide Defenseurs report and is the costliest to leak.
const MALFORMED = [
["raw token without the Bearer scheme", TOKEN],
["lowercase scheme", `bearer ${TOKEN}`],
["scheme with no token", "Bearer"],
];
test.each(MALFORMED)("401 on GET /defenseurs — %s", async (_label, header) => {
const { status, body } = await request("/defenseurs", { auth: header });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
});
describe("auth gate — fail-closed when HEALTH_TOKEN is unset", () => {
test("401 on GET /defenseurs even with a well-formed bearer token", async () => {
delete process.env.HEALTH_TOKEN;
await restartServer();
const { status, body } = await request("/defenseurs", { auth: `Bearer ${TOKEN}` });
expect(status).toBe(401);
expect(body.error).toBe("HEALTH_TOKEN not configured");
});
});
describe("routing", () => {
test("404 on an unknown route", async () => {
const { status, body } = await request("/nope");
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
test.each(ROUTES)("404 on POST %s (method not allowed)", async (route) => {
const { status, body } = await request(route, { method: "POST" });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
// Current ordering: the route/method check runs BEFORE authentication, so an
// unauthenticated caller gets 404 rather than 401 on these. Documented as-is;
// any refactor that flips the order will turn these red on purpose.
test("404 (not 401) on an unknown route without Authorization", async () => {
const { status, body } = await request("/nope", { auth: null });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
test("404 (not 401) on POST /health without Authorization", async () => {
const { status, body } = await request("/health", { method: "POST", auth: null });
expect(status).toBe(404);
expect(body.error).toBe("Not found");
});
});
// The ingest path is the only write surface on this service, and the only one
// reachable with a token that is NOT the read token. Both directions of that
// separation are pinned here: a read token must not write, a write token must
// not read. The gate answers before the request body is ever buffered, so an
// unauthenticated caller cannot make the server hold 4 KiB on its behalf.
describe("auth gate — ingest route", () => {
test("401 on POST /hosts/<id> with no Authorization header", async () => {
const { status, body } = await request("/hosts/thinkpad", { method: "POST", auth: null });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 on POST /hosts/<id> with the read token", async () => {
const { status, body } = await request("/hosts/thinkpad", {
method: "POST",
auth: `Bearer ${TOKEN}`,
});
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
test("401 on GET /hosts with the ingest token", async () => {
const { status, body } = await request("/hosts", { auth: `Bearer ${INGEST_TOKEN}` });
expect(status).toBe(401);
expect(body.error).toBe("Unauthorized");
});
// An unknown host id never reaches the handler: the route regex is the
// path-traversal control, so a malformed id is a 404, not a 403 — and that
// 404 arrives before authentication, like every other routing decision.
test("404 (not 401, not 403) on POST /hosts/../../etc/passwd", async () => {
const { status } = await request("/hosts/../../etc/passwd", { method: "POST", auth: null });
expect(status).toBe(404);
});
});

View file

@ -1,139 +0,0 @@
const http = require("node:http");
const { setTimeout: delay } = require("node:timers/promises");
const { getCpuPercent, getDisk, collectMetrics } = require("../metrics.js");
const TOKEN = "test-token";
let logtoServer;
let logtoUrl;
let logtoDelayMs;
let server;
let baseUrl;
let handler;
// Stand-in for the Logto .well-known endpoint, with a settable delay so we can
// simulate a slow IdP without touching the network.
function startLogtoStub() {
logtoDelayMs = 0;
logtoServer = http.createServer(async (req, res) => {
if (logtoDelayMs > 0) await delay(logtoDelayMs);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ issuer: "http://127.0.0.1/oidc" }));
});
return new Promise((resolve) => {
logtoServer.listen(0, "127.0.0.1", () => {
const { port } = logtoServer.address();
logtoUrl = `http://127.0.0.1:${port}/oidc/.well-known/openid-configuration`;
resolve();
});
});
}
function startServer() {
// Fresh import so module-level constants pick up the stubbed env.
delete require.cache[require.resolve("../index.js")];
({ handler } = require("../index.js"));
server = http.createServer(handler);
return new Promise((resolve) => {
server.listen(0, () => {
const { port } = server.address();
baseUrl = `http://127.0.0.1:${port}`;
resolve();
});
});
}
function close(target) {
return new Promise((resolve) => {
if (!target) return resolve();
target.close(() => resolve());
});
}
async function get(path, { auth = `Bearer ${TOKEN}` } = {}) {
const headers = {};
if (auth) headers.Authorization = auth;
const res = await fetch(`${baseUrl}${path}`, { headers });
const body = await res.json().catch(() => ({}));
return { status: res.status, body };
}
beforeEach(async () => {
await startLogtoStub();
process.env.HEALTH_TOKEN = TOKEN;
process.env.LOGTO_HEALTH_URL = logtoUrl;
await startServer();
});
afterEach(async () => {
await close(server);
await close(logtoServer);
});
describe("metrics module", () => {
test("exposes getCpuPercent, getDisk and collectMetrics", () => {
expect(typeof getCpuPercent).toBe("function");
expect(typeof getDisk).toBe("function");
expect(typeof collectMetrics).toBe("function");
});
test("getDisk returns the four numeric disk fields", () => {
const disk = getDisk();
expect(Object.keys(disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
for (const value of Object.values(disk)) expect(typeof value).toBe("number");
});
test("collectMetrics returns the cpu/memory/disk slice of /health", async () => {
const metrics = await collectMetrics();
expect(Object.keys(metrics)).toEqual(["cpu", "memory", "disk"]);
expect(Object.keys(metrics.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
expect(Object.keys(metrics.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(metrics.cpu.cores).toBeGreaterThan(0);
expect(Array.isArray(metrics.cpu.loadAvg)).toBe(true);
});
});
describe("GET /health", () => {
test("401 on invalid token", async () => {
const { status } = await get("/health", { auth: "Bearer wrong" });
expect(status).toBe(401);
});
test("200 returns the documented payload, field for field", async () => {
const { status, body } = await get("/health");
expect(status).toBe(200);
// Key order is part of the contract consumed by the admin dashboard.
expect(Object.keys(body)).toEqual([
"timestamp",
"hostname",
"uptime",
"cpu",
"memory",
"disk",
"logto",
]);
expect(Object.keys(body.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]);
expect(Object.keys(body.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(Object.keys(body.disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]);
expect(body.logto.status).toBe("up");
expect(typeof body.logto.responseTimeMs).toBe("number");
expect(typeof body.uptime).toBe("number");
expect(typeof body.hostname).toBe("string");
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
});
// Latency regression guard. Metrics collection (500ms CPU sample) and the
// Logto check must run concurrently: serializing them would make /health take
// 500ms + the Logto response time. With a 1200ms Logto, concurrent lands at
// ~1200ms while serialized lands at ~1700ms — only the former clears 1.5s.
test("stays under 1.5s with a slow Logto (metrics stay concurrent)", async () => {
logtoDelayMs = 1200;
const start = performance.now();
const { status, body } = await get("/health");
const elapsed = performance.now() - start;
expect(status).toBe(200);
expect(body.logto.status).toBe("up");
expect(elapsed).toBeLessThan(1500);
}, 15000);
});

File diff suppressed because it is too large Load diff

View file

@ -1,177 +0,0 @@
# Workstation agent
Collects one snapshot of the machine it runs on and pushes it to
`POST /hosts/<id>` on the health API. Cron runs it every 5 minutes; the
dashboard reads the result through `GET /hosts`.
Zero dependencies — `node:http` / `node:https` and the `metrics.js` already
shared with the server. Node 22+.
| File | Role |
|------|------|
| `push-metrics.js` | Collect, build the payload, POST once, exit |
| `run-push.sh` | Cron wrapper: load the env file, check the install, exec node |
This directory is **not** part of the server image: the `Dockerfile` copies
`package.json index.js metrics.js` one by one, and a test pins that it stays
that way. The agent ships by copying files onto a workstation, never by deploy.
## What it deliberately does not do
**No queue, no replay.** One run is one attempt. A push that fails is dropped,
logged, and the process exits non-zero — the next beat is five minutes away and
a heartbeat from five minutes ago describes a machine that no longer exists.
Nothing is spooled to disk, so nothing can ever be replayed to make the
dashboard show a past that is no longer true.
**No secret in the logs.** `run-push.sh` is meant to be piped into `logger`, so
everything the agent prints ends up in syslog and journald permanently. The
failure path therefore reports **only** `err.code` and the HTTP status — never
an error object, never the request options, never a response body, and never a
header. That is also why `run-push.sh` must never gain a `set -x`: the shell
would echo the token as it sources the env file.
The cost is real and accepted: a `400` tells you the server rejected the
payload, not why. The reason is in the server's logs, and `--dry-run` (below)
shows you the exact payload that was sent.
## Install on a new workstation
### 1. Freeze a copy
The cron must point at a copy, **never at the git working tree**. The server is
deployed by a manual trigger, so the checked-out repo drifts freely: a branch
checkout in `~/claude-code/vps-health-api` would silently change — or break —
the heartbeat of the workstation, and nothing would say so.
`push-metrics.js` loads `metrics.js` from one directory up, so the copy keeps
the repo layout:
```
mkdir -p ~/.local/share/maximus-host-agent/agent
cp metrics.js ~/.local/share/maximus-host-agent/
cp agent/push-metrics.js agent/run-push.sh ~/.local/share/maximus-host-agent/agent/
chmod +x ~/.local/share/maximus-host-agent/agent/run-push.sh
```
Re-run those three `cp` after any change to the agent — that copy step *is* the
deployment.
### 2. Write the env file
`~/.config/maximus-host-agent.env`, mode `600` (the agent warns on any other
mode). Use `printf`, not a heredoc: a trailing newline or space inside the token
value is the most common install failure.
```
HOSTS_API_URL=https://health.lacompagniemaximus.com
HOSTS_INGEST_TOKEN=<the ingest token>
HOST_ID=thinkpad
# NODE_BIN=/usr/bin/node # only if cron cannot find node (see below)
# HOST_AGENT_TIMEOUT_MS=10000 # push timeout, wall-clock; default 10000
```
```
chmod 600 ~/.config/maximus-host-agent.env
```
`HOSTS_INGEST_TOKEN` is the write-only token: it can push snapshots for an
allowlisted id and nothing else. It is deliberately *not* `HEALTH_TOKEN`, so
that the two can be rotated independently and so a workstation that only pushes
never needs the read token.
Do not read more into that separation than it gives you. On the ThinkPad it
buys no containment at all: that machine already stores `HEALTH_TOKEN` in
cleartext for the `defenseur-auto` cron, so losing the laptop compromises both
tokens. **Rotate them together** — see `secret-rotation-ops.md`.
`HOST_ID` must match `^[a-z0-9][a-z0-9-]{0,31}$` and be listed in the server's
`HOSTS_ALLOWED_IDS`, otherwise the push comes back `403`.
### 3. Dry run
Collect and print the payload without touching the network or needing any
config:
```
node ~/.local/share/maximus-host-agent/agent/push-metrics.js --dry-run
```
Then one real push, by hand, before installing the cron:
```
~/.local/share/maximus-host-agent/agent/run-push.sh
# host-agent: ok id=thinkpad status=204 hostname=thinkpad-x1
```
Confirm the server side (from a machine that holds `HEALTH_TOKEN`):
```
curl -H "Authorization: Bearer $HEALTH_TOKEN" \
https://health.lacompagniemaximus.com/hosts
```
The entry for your id should show `online: true` and a small `ageSeconds`.
### 4. Install the cron
```
crontab -e
```
```
*/5 * * * * $HOME/.local/share/maximus-host-agent/agent/run-push.sh 2>&1 | logger -t host-agent
```
Read what it did:
```
journalctl -t host-agent --since -1h
```
If the line works by hand but produces nothing under cron, it is almost always
`node`: cron's `PATH` is `/usr/bin:/bin`, and a node installed by nvm or under
`/usr/local` is not on it. Set `NODE_BIN` to an absolute path in the env file —
the wrapper says so explicitly rather than failing silently.
## One id per workstation
The server keeps **one file per host id** and the last write wins. Two
workstations configured with the same `HOST_ID` therefore overwrite each other
every five minutes, and nothing anywhere reports an error: the dashboard shows
one host that looks perfectly healthy while its numbers come from whichever
machine pushed last.
The signal to watch for is the `hostname` field in `GET /hosts` **changing
between polls** while the id stays the same — plus CPU and memory that jump
around without pattern. If you see that, two machines are sharing an id.
Give every workstation its own id and add it to `HOSTS_ALLOWED_IDS` on the
server before installing the agent on it.
## Exit codes
| Code | Meaning |
|------|---------|
| 0 | Snapshot accepted (server answered 2xx, normally `204`) |
| 1 | Install problem: env file missing, a variable unset, malformed `HOST_ID`, node not found, `metrics.js` not found next to the agent |
| 2 | The push did not land: network error (`code=…`) or a non-2xx answer (`HTTP …`) |
A `1` needs a human on the workstation. A `2` usually fixes itself on the next
beat; a `2` that repeats for an hour means the API or the link is down.
## Payload
Exactly the `GET /health` body minus `logto` and `timestamp`:
```
{ hostname, uptime,
cpu: { model, cores, loadAvg, usagePercent },
memory: { totalGB, usedGB, freeGB, usagePercent },
disk: { totalGB, usedGB, freeGB, usagePercent } }
```
Sharing `metrics.js` with the server keeps the two in step, but it is not the
guarantee — the frozen copy can lag behind a deployed server. The real contract
is the server's own validation, and `__tests__/agent.test.js` checks the agent's
payload against it (including a real snapshot through the real handler).

View file

@ -1,309 +0,0 @@
#!/usr/bin/env node
// Local workstation agent: collect one snapshot and POST it to /hosts/<id>.
// Runs from cron every 5 minutes on the ThinkPad. Zero runtime dependencies —
// node:http / node:https only, same posture as the server it talks to.
//
// Two properties are load-bearing and easy to break by accident.
//
// * NO QUEUE, NO REPLAY. One run = one attempt. A missed beat is replaced by
// the next one five minutes later; a stale heartbeat has no value, and a
// spool file would only ever let the dashboard display a past that is no
// longer true. Every failure exits non-zero and stops there.
// * THE FAILURE PATH NEVER PRINTS A REQUEST OBJECT. run-push.sh pipes this
// process' output into `logger -t host-agent`, so anything written here
// lands in /var/log/syslog and journald permanently. An error object or an
// options bag carrying the Authorization header would persist the ingest
// token on disk, in cleartext, forever. Failures are therefore rebuilt from
// a fixed template plus err.code or the HTTP status — nothing else.
// __tests__/agent.test.js asserts no fragment of the token survives into
// the process output.
const os = require("node:os");
const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const DEFAULT_TIMEOUT_MS = 10000;
// Exit codes are the only signal cron has. 1 means "the install is wrong"
// (fix the env file), 2 means "the push did not land" (network or server).
const EXIT_CONFIG = 1;
const EXIT_PUSH = 2;
// Mirrors HOST_ID_PATTERN in index.js. Checked client-side so a typo in the env
// file fails with a readable message instead of an opaque 404 from the server.
const HOST_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
// --- Payload ----------------------------------------------------------------
function pickUsage(value) {
return {
totalGB: value.totalGB,
usedGB: value.usedGB,
freeGB: value.freeGB,
usagePercent: value.usagePercent,
};
}
// Build the POST /hosts/<id> body: the GET /health payload minus `logto` and
// `timestamp`. Fields are copied one by one rather than spread, so a future
// addition to collectMetrics() cannot silently widen what leaves the
// workstation. `uptime` is floored exactly like getHealth() does.
function buildSnapshot(metrics) {
return {
hostname: os.hostname(),
uptime: Math.floor(os.uptime()),
cpu: {
model: metrics.cpu.model,
cores: metrics.cpu.cores,
loadAvg: metrics.cpu.loadAvg,
usagePercent: metrics.cpu.usagePercent,
},
memory: pickUsage(metrics.memory),
disk: pickUsage(metrics.disk),
};
}
// --- Failure messages -------------------------------------------------------
//
// The ONLY three places a failure string is built. Keep it that way: every
// caller below reports through one of these, none of them accepts an error
// object, and none of them is given the token or the request options.
function configFailure(reason) {
return new Error(`configuration error: ${reason}`);
}
function transportFailure(code) {
return new Error(`push failed: request error (code=${code || "UNKNOWN"})`);
}
function httpFailure(statusCode) {
return new Error(`push failed: HTTP ${statusCode}`);
}
// --- Config -----------------------------------------------------------------
function readConfig(env) {
const apiUrl = (env.HOSTS_API_URL || "").trim();
const token = env.HOSTS_INGEST_TOKEN || "";
const hostId = (env.HOST_ID || "").trim();
if (!apiUrl) return { error: configFailure("HOSTS_API_URL is not set") };
if (!token) return { error: configFailure("HOSTS_INGEST_TOKEN is not set") };
if (!hostId) return { error: configFailure("HOST_ID is not set") };
if (!HOST_ID_RE.test(hostId)) {
return {
error: configFailure("HOST_ID must match ^[a-z0-9][a-z0-9-]{0,31}$"),
};
}
// Shape-check the URL here too, so a broken env file exits 1 ("fix the
// install") rather than 2 ("the network is having a bad day"). postSnapshot
// repeats the check because it is also called directly by the tests.
let target;
try {
target = buildTargetUrl(apiUrl, hostId);
} catch {
return { error: configFailure("HOSTS_API_URL is not a valid URL") };
}
if (target.protocol !== "http:" && target.protocol !== "https:") {
return { error: configFailure("HOSTS_API_URL must be http:// or https://") };
}
const timeoutRaw = parseInt(env.HOST_AGENT_TIMEOUT_MS || "", 10);
const timeoutMs =
Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_TIMEOUT_MS;
return { config: { apiUrl, token, hostId, timeoutMs } };
}
// --- Delivery ---------------------------------------------------------------
function buildTargetUrl(apiUrl, hostId) {
const base = String(apiUrl).replace(/\/+$/, "");
return new URL(`${base}/hosts/${hostId}`);
}
// One attempt, hard-bounded in wall-clock time. Resolves to { statusCode } on
// 2xx, rejects with a template message otherwise. No retry lives here or above.
function postSnapshot({ apiUrl, token, hostId, snapshot, timeoutMs = DEFAULT_TIMEOUT_MS }) {
return new Promise((resolve, reject) => {
let target;
try {
target = buildTargetUrl(apiUrl, hostId);
} catch {
reject(configFailure("HOSTS_API_URL is not a valid URL"));
return;
}
const client =
target.protocol === "https:" ? https : target.protocol === "http:" ? http : null;
if (!client) {
reject(configFailure("HOSTS_API_URL must be http:// or https://"));
return;
}
const body = Buffer.from(JSON.stringify(snapshot), "utf-8");
let settled = false;
let req = null;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(arg);
};
// Wall-clock ceiling over the whole exchange: DNS, connect, TLS, response.
// The `timeout` request option only covers socket inactivity, which can add
// up past the budget on a flaky link. destroy() surfaces as an 'error' with
// the code below, so the timeout reports as ETIMEDOUT and nothing else.
const timer = setTimeout(() => {
if (settled || !req) return;
req.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }));
}, timeoutMs);
try {
req = client.request(
target,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": body.length,
Authorization: `Bearer ${token}`,
},
},
(res) => {
const { statusCode } = res;
// Drain without reading: the response body is never inspected and
// never logged. A 400 says the payload was rejected; the reason is in
// the server's logs, where it can be read without a token nearby.
res.resume();
res.on("end", () => {
if (statusCode >= 200 && statusCode < 300) {
finish(resolve, { statusCode });
} else {
finish(reject, httpFailure(statusCode));
}
});
res.on("error", (err) => finish(reject, transportFailure(err && err.code)));
},
);
} catch (err) {
// request() throws synchronously on an unusable header — in practice a
// token with a trailing newline, the classic env-file mistake. Node's
// ERR_INVALID_CHAR names the header but never quotes its value; it is
// still converted here rather than propagated, so no error object from
// the HTTP stack can ever reach a console call.
if (err && err.code === "ERR_INVALID_CHAR") {
finish(
reject,
configFailure(
"HOSTS_INGEST_TOKEN contains an invalid character (a trailing newline in the env file is the usual cause)",
),
);
} else {
finish(reject, transportFailure(err && err.code));
}
return;
}
// err.code and NOTHING else. err.message can carry the resolved address,
// and the error object dragged into a console call would carry the whole
// request — headers included.
req.on("error", (err) => finish(reject, transportFailure(err && err.code)));
req.end(body);
});
}
// --- Entry point ------------------------------------------------------------
// metrics.js is shared with the server and lives one directory up. The frozen
// install copy must keep that layout (see README) — when it does not, say so in
// one line rather than let cron mail a MODULE_NOT_FOUND stack.
function loadCollectMetrics() {
try {
return require(path.join(__dirname, "..", "metrics.js")).collectMetrics;
} catch (err) {
if (err && err.code === "MODULE_NOT_FOUND") {
throw configFailure(
"metrics.js not found one level above the agent — the install copy must keep the repo layout (see agent/README.md)",
);
}
throw err;
}
}
async function main(argv = process.argv.slice(2), env = process.env) {
// Dry run: collect and print the payload, touch no network, need no config.
// This is what commissioning runs first, before the env file even exists.
const dryRun = argv.includes("--dry-run");
// Config first: the CPU sample below costs 500ms, and there is no point
// spending it to then discover HOST_ID is a typo.
let config = null;
if (!dryRun) {
const result = readConfig(env);
if (result.error) {
console.error(`host-agent: ${result.error.message}`);
return EXIT_CONFIG;
}
config = result.config;
}
let snapshot;
try {
const collectMetrics = loadCollectMetrics();
snapshot = buildSnapshot(await collectMetrics());
} catch (err) {
console.error(`host-agent: ${err.message}`);
return EXIT_CONFIG;
}
if (dryRun) {
console.log(JSON.stringify(snapshot, null, 2));
return 0;
}
try {
const { statusCode } = await postSnapshot({ ...config, snapshot });
console.log(
`host-agent: ok id=${config.hostId} status=${statusCode} hostname=${snapshot.hostname}`,
);
return 0;
} catch (err) {
console.error(`host-agent: ${err.message}`);
return EXIT_PUSH;
}
}
if (require.main === module) {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
// Nothing should reach here — every path above is handled. Print the
// message only, never the error object: a stack rendered by console.error
// is fine, but an object argument is what leaks headers.
console.error(`host-agent: unexpected failure: ${err && err.message}`);
process.exitCode = EXIT_PUSH;
},
);
}
module.exports = {
buildSnapshot,
buildTargetUrl,
postSnapshot,
readConfig,
main,
DEFAULT_TIMEOUT_MS,
EXIT_CONFIG,
EXIT_PUSH,
HOST_ID_RE,
};

View file

@ -1,70 +0,0 @@
#!/bin/sh
#
# Cron wrapper for push-metrics.js: load the secrets, check the install, hand
# over to node. Every five minutes, forever, unattended.
#
# NEVER add `set -x`. This script is meant to be piped into `logger`, and the
# shell would echo the `. "$ENV_FILE"` expansion — the ingest token — straight
# into /var/log/syslog and journald, where it would stay. Same reason nothing
# below ever echoes a variable that holds a secret: the checks print the NAME of
# what is missing, never its value.
#
# Suggested crontab line (see README.md):
# */5 * * * * $HOME/.local/share/maximus-host-agent/agent/run-push.sh 2>&1 | logger -t host-agent
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
AGENT="$SCRIPT_DIR/push-metrics.js"
# Overridable for testing only; cron uses the default.
ENV_FILE="${HOST_AGENT_ENV_FILE:-$HOME/.config/maximus-host-agent.env}"
if [ ! -f "$ENV_FILE" ]; then
echo "host-agent: env file not found: $ENV_FILE" >&2
exit 1
fi
# A warning, not a refusal: a wrong mode bit is worth shouting about, but it is
# not worth silencing the heartbeat of the whole workstation over.
FILE_MODE=$(stat -c "%a" "$ENV_FILE" 2>/dev/null || echo "")
if [ -n "$FILE_MODE" ] && [ "$FILE_MODE" != "600" ]; then
echo "host-agent: warning: $ENV_FILE is mode $FILE_MODE, expected 600" >&2
fi
# shellcheck source=/dev/null
. "$ENV_FILE"
if [ -z "${HOSTS_API_URL:-}" ]; then
echo "host-agent: HOSTS_API_URL is not set in $ENV_FILE" >&2
exit 1
fi
if [ -z "${HOSTS_INGEST_TOKEN:-}" ]; then
echo "host-agent: HOSTS_INGEST_TOKEN is not set in $ENV_FILE" >&2
exit 1
fi
if [ -z "${HOST_ID:-}" ]; then
echo "host-agent: HOST_ID is not set in $ENV_FILE" >&2
exit 1
fi
if [ ! -f "$AGENT" ]; then
echo "host-agent: agent not found: $AGENT" >&2
exit 1
fi
# cron's PATH is famously short (/usr/bin:/bin), and a node installed through
# nvm or /usr/local lives outside it. Set NODE_BIN in the env file when
# `command -v node` comes up empty under cron but works in a login shell.
NODE_BIN="${NODE_BIN:-node}"
if ! command -v "$NODE_BIN" >/dev/null 2>&1; then
echo "host-agent: node not found (NODE_BIN=$NODE_BIN); set NODE_BIN in $ENV_FILE" >&2
exit 1
fi
# HOST_AGENT_TIMEOUT_MS is optional and read by push-metrics.js. It must be
# exported too, otherwise setting it in the env file silently does nothing and
# the agent always runs on its 10s default.
export HOSTS_API_URL HOSTS_INGEST_TOKEN HOST_ID HOST_AGENT_TIMEOUT_MS
exec "$NODE_BIN" "$AGENT" "$@"

565
index.js
View file

@ -1,17 +1,9 @@
const http = require("node:http");
const os = require("node:os");
const {
readFileSync,
readdirSync,
existsSync,
mkdirSync,
writeFileSync,
renameSync,
unlinkSync,
} = require("node:fs");
const { execSync } = require("node:child_process");
const { readFileSync, readdirSync, existsSync } = require("node:fs");
const path = require("node:path");
const { createHash, timingSafeEqual } = require("node:crypto");
const { collectMetrics } = require("./metrics.js");
const { setTimeout: delay } = require("node:timers/promises");
const PORT = parseInt(process.env.PORT || "3001", 10);
const TOKEN = process.env.HEALTH_TOKEN;
@ -28,52 +20,6 @@ const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
const VALID_SEVERITIES = Object.keys(SEVERITY_RANK);
const VALID_CATEGORIES = ["deps", "secrets", "code", "acces", "infra"];
// --- Workstation snapshots (POST /hosts/<id>, GET /hosts) -------------------
const HOSTS_DIR = process.env.HOSTS_DIR || "/data/hosts";
const HOSTS_INGEST_TOKEN = process.env.HOSTS_INGEST_TOKEN;
const MAX_BODY_BYTES = 4096;
const MAX_STRING_LENGTH = 128;
// Single source of truth for what a host id may look like. The allowlist check
// and the route matcher are both built from it, so they can never drift apart.
//
// This narrow pattern IS the path-traversal control. Verified in Node: URL()
// normalises `/hosts/../../etc/passwd` to `/etc/passwd`, while `..%2f..%2f` and
// `THINKPAD` simply fail the match — every hostile id therefore lands on 404,
// before any handler runs. 403 is reserved for well-formed ids that are not in
// the allowlist. Never widen this to `^/hosts/(.+)$` to make a test pass.
const HOST_ID_PATTERN = "[a-z0-9][a-z0-9-]{0,31}";
const HOST_ID_RE = new RegExp(`^${HOST_ID_PATTERN}$`);
const HOST_ROUTE_RE = new RegExp(`^/hosts/(${HOST_ID_PATTERN})$`);
const staleSecondsRaw = parseInt(process.env.HOSTS_STALE_SECONDS || "", 10);
const HOSTS_STALE_SECONDS =
Number.isFinite(staleSecondsRaw) && staleSecondsRaw > 0 ? staleSecondsRaw : 900;
// Parse HOSTS_ALLOWED_IDS once at startup and drop anything that is not a valid
// host id. Entries feed path.join(HOSTS_DIR, `${id}.json`), so a stray space or
// an entry like `../defenseurs/status` must never survive into a file path.
// Rejections are logged rather than silently swallowed: a typo in the env var
// would otherwise look exactly like a workstation that never checked in.
function parseAllowedIds(raw) {
const allowed = new Set();
for (const entry of String(raw).split(",")) {
const id = entry.trim();
if (!id) continue;
if (!HOST_ID_RE.test(id)) {
console.warn(
`WARNING: HOSTS_ALLOWED_IDS entry rejected (not a valid host id): ${safeLogValue(id)}`,
);
continue;
}
allowed.add(id);
}
return allowed;
}
const HOSTS_ALLOWED_IDS = parseAllowedIds(process.env.HOSTS_ALLOWED_IDS || "thinkpad");
// Severity filter. Asymmetric rule (issue #3):
// - no threshold -> MEDIUM+HIGH+CRITICAL (default hides noise LOW+INFO)
// - threshold "INFO" -> INFO only (explicit opt-in)
@ -92,9 +38,34 @@ if (!TOKEN) {
console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed).");
}
// No startup warning for a missing HOSTS_INGEST_TOKEN: the ingestion path
// fail-closes to 503 and logs the reason on the request that hits it, which is
// the moment an operator actually needs to see it.
function readProcStat() {
try {
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
const parts = line.split(/\s+/).slice(1).map(Number);
const idle = parts[3] + parts[4];
const total = parts.reduce((a, b) => a + b, 0);
return { idle, total };
} catch {
return null;
}
}
async function getCpuPercent() {
const t1 = readProcStat();
if (!t1) return 0;
const { idle: idle1, total: total1 } = t1;
// Sample over 500ms without blocking the event loop, so other async work
// (e.g. the Logto healthcheck) can run concurrently.
await delay(500);
const t2 = readProcStat();
if (!t2) return 0;
const dIdle = t2.idle - idle1;
const dTotal = t2.total - total1;
if (dTotal === 0) return 0;
return Math.round((1 - dIdle / dTotal) * 100);
}
async function getLogtoHealth() {
const ac = new AbortController();
@ -114,6 +85,22 @@ async function getLogtoHealth() {
}
}
function getDisk() {
try {
// Alpine df doesn't support --output, use standard POSIX format
const out = execSync("df -k /", { encoding: "utf-8" });
const parts = out.trim().split("\n")[1].trim().split(/\s+/);
// df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted
const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1);
const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1);
const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1);
const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0;
return { totalGB, usedGB, freeGB, usagePercent };
} catch {
return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 };
}
}
// 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
@ -220,11 +207,13 @@ function findLatestReportForAgent(agent) {
}
async function getHealth() {
// Keep the Promise.all: the 500ms CPU sample inside collectMetrics() and the
// up-to-3s Logto check must stay concurrent. Awaiting them one after the
// other would push the p99 of /health to ~3.5s.
const [metrics, logto] = await Promise.all([
collectMetrics(),
const cpus = os.cpus();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const [cpuUsagePercent, logto] = await Promise.all([
getCpuPercent(),
getLogtoHealth(),
]);
@ -232,231 +221,52 @@ async function getHealth() {
timestamp: new Date().toISOString(),
hostname: os.hostname(),
uptime: Math.floor(os.uptime()),
...metrics,
cpu: {
model: cpus[0]?.model?.trim() || "unknown",
cores: cpus.length,
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
usagePercent: cpuUsagePercent,
},
memory: {
totalGB: +(totalMem / 1e9).toFixed(1),
usedGB: +(usedMem / 1e9).toFixed(1),
freeGB: +(freeMem / 1e9).toFixed(1),
usagePercent: Math.round((usedMem / totalMem) * 100),
},
disk: getDisk(),
logto,
};
}
// --- Hosts helpers ----------------------------------------------------------
async function handler(req, res) {
res.setHeader("Content-Type", "application/json");
// Printable-ASCII-only, length-capped rendering for anything attacker-supplied
// that reaches a log line (X-Real-IP, host id, env entries). Keeps a crafted
// value from forging extra log records.
function safeLogValue(value, max = 64) {
return String(value).replace(/[^\x20-\x7e]/g, "?").slice(0, max);
}
// 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;
// Log every 401/403. This is the first publicly writable surface on the API:
// without a trace, a token-guessing campaign would leave nothing behind.
function logRejection(req, status, reason, id) {
const ip = safeLogValue(req.headers["x-real-ip"] || req.socket?.remoteAddress || "unknown");
const method = safeLogValue(req.method, 8);
const url = safeLogValue(req.url, 120);
console.warn(
`[auth] ${status} ${method} ${url} ip=${ip} id=${id ? safeLogValue(id, 32) : "-"} reason=${reason}`,
);
}
// Constant-time bearer comparison for the ingestion path. Both sides are hashed
// first, so the buffers are always 32 bytes: timingSafeEqual never throws on
// mismatched lengths, and the length of the real token cannot leak.
//
// Deliberately scoped to the new write path. Realigning the existing
// HEALTH_TOKEN comparison touches every read route in production and is tracked
// separately (issue #17).
function tokenMatches(provided, expected) {
if (typeof provided !== "string" || typeof expected !== "string") return false;
const a = createHash("sha256").update(provided).digest();
const b = createHash("sha256").update(expected).digest();
return timingSafeEqual(a, b);
}
// Read the request body with a hard 4 KiB ceiling. The counter runs on the
// bytes actually received, never on Content-Length: a client can lie in the
// header, and chunked transfer-encoding omits it altogether.
//
// Resolves to the raw string, or to null when the response has already been
// written (413) or the socket died — in which case the caller must not answer.
function readBody(req, res) {
return new Promise((resolve) => {
const chunks = [];
let size = 0;
let settled = false;
req.on("data", (chunk) => {
if (settled) return;
size += chunk.length;
if (size > MAX_BODY_BYTES) {
settled = true;
req.pause();
res.writeHead(413);
// Destroy only once the 413 has been flushed: req.destroy() tears down
// the socket, so cutting first would leave the client with a network
// error instead of a status code.
res.end(JSON.stringify({ error: "Payload too large" }), () => req.destroy());
resolve(null);
const validRoutes = ["/health", "/defenseurs", "/defenseurs/findings", "/reports/scans"];
if (req.method !== "GET" || !validRoutes.includes(pathname)) {
res.writeHead(404);
res.end(JSON.stringify({ error: "Not found" }));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks).toString("utf-8"));
});
req.on("error", () => {
if (settled) return;
settled = true;
resolve(null);
});
});
if (!TOKEN) {
res.writeHead(401);
res.end(JSON.stringify({ error: "HEALTH_TOKEN not configured" }));
return;
}
function cleanString(value) {
if (typeof value !== "string") return null;
return value.slice(0, MAX_STRING_LENGTH);
const auth = req.headers["authorization"];
if (auth !== `Bearer ${TOKEN}`) {
res.writeHead(401);
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
function cleanNumber(value) {
return Number.isFinite(value) ? value : null;
}
// { totalGB, usedGB, freeGB, usagePercent } — the shape shared by memory and disk.
function sanitizeUsage(value, label) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return { error: `${label} must be an object` };
}
const out = {};
for (const key of ["totalGB", "usedGB", "freeGB", "usagePercent"]) {
const num = cleanNumber(value[key]);
if (num === null) return { error: `${label}.${key} must be a finite number` };
out[key] = num;
}
return { value: out };
}
function sanitizeCpu(value) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return { error: "cpu must be an object" };
}
const model = cleanString(value.model);
if (model === null) return { error: "cpu.model must be a string" };
const cores = cleanNumber(value.cores);
if (cores === null) return { error: "cpu.cores must be a finite number" };
const usagePercent = cleanNumber(value.usagePercent);
if (usagePercent === null) return { error: "cpu.usagePercent must be a finite number" };
if (!Array.isArray(value.loadAvg)) return { error: "cpu.loadAvg must be an array" };
const loadAvg = [];
for (const entry of value.loadAvg.slice(0, 3)) {
const num = cleanNumber(entry);
if (num === null) return { error: "cpu.loadAvg must contain finite numbers" };
loadAvg.push(num);
}
return { value: { model, cores, loadAvg, usagePercent } };
}
// Rebuild the persisted object field by field from a whitelist — never store
// the payload verbatim. The file is read back by GET /hosts and ends up in the
// admin dashboard's React tree, so an unknown key (`__proto__` among them) or a
// 4 KiB hostname must never make it that far.
function sanitizeSnapshot(payload) {
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
return { error: "body must be a JSON object" };
}
const hostname = cleanString(payload.hostname);
if (hostname === null) return { error: "hostname must be a string" };
const uptime = cleanNumber(payload.uptime);
if (uptime === null) return { error: "uptime must be a finite number" };
const cpu = sanitizeCpu(payload.cpu);
if (cpu.error) return cpu;
const memory = sanitizeUsage(payload.memory, "memory");
if (memory.error) return memory;
const disk = sanitizeUsage(payload.disk, "disk");
if (disk.error) return disk;
return {
snapshot: {
hostname,
uptime,
cpu: cpu.value,
memory: memory.value,
disk: disk.value,
},
};
}
// Atomic write: temp file in the SAME directory, then renameSync. rename is
// atomic within a filesystem, so a concurrent GET /hosts either sees the old
// snapshot or the new one — never a half-written JSON.
function writeHostSnapshot(id, record) {
mkdirSync(HOSTS_DIR, { recursive: true });
const finalPath = path.join(HOSTS_DIR, `${id}.json`);
const tmpPath = path.join(HOSTS_DIR, `.${id}.${process.pid}.${Date.now()}.tmp`);
try {
writeFileSync(tmpPath, JSON.stringify(record), { mode: 0o600 });
renameSync(tmpPath, finalPath);
} catch (err) {
try {
unlinkSync(tmpPath);
} catch {
// Best effort — the temp file may never have been created.
}
throw err;
}
}
// Read one persisted snapshot. Anything missing, unparsable, off-shape or
// without a usable server timestamp degrades to null: GET /hosts then reports
// that entry as neverSeen instead of failing the whole response.
//
// The file was written by this service, but it lives on a writable bind-mount:
// it is re-run through the same whitelist on the way out rather than trusted.
function readHostSnapshot(id) {
const file = path.join(HOSTS_DIR, `${id}.json`);
let parsed;
try {
parsed = JSON.parse(readFileSync(file, "utf-8"));
} catch (err) {
if (err.code !== "ENOENT") {
console.error(`[hosts] failed to read snapshot for ${id}:`, err.message);
}
return null;
}
const result = sanitizeSnapshot(parsed);
if (result.error) {
console.error(`[hosts] snapshot for ${id} rejected on read: ${result.error}`);
return null;
}
const receivedAt = typeof parsed.receivedAt === "string" ? parsed.receivedAt : null;
if (!receivedAt || Number.isNaN(new Date(receivedAt).getTime())) {
console.error(`[hosts] snapshot for ${id} has no usable receivedAt`);
return null;
}
return { ...result.snapshot, receivedAt };
}
// --- Route handlers ---------------------------------------------------------
async function handleHealth(req, res) {
try {
const data = await getHealth();
res.writeHead(200);
res.end(JSON.stringify(data));
} catch (err) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
}
function handleDefenseurs(req, res) {
if (pathname === "/defenseurs") {
const statusPath = process.env.DEFENSEURS_STATUS_PATH || "/data/defenseurs/status.json";
try {
const status = readFileSync(statusPath, "utf-8");
@ -466,12 +276,13 @@ function handleDefenseurs(req, res) {
res.writeHead(200);
res.end(JSON.stringify({ status: "no_data" }));
}
return;
}
function handleFindings(req, res, ctx) {
const project = ctx.parsedUrl.searchParams.get("project");
const category = ctx.parsedUrl.searchParams.get("category");
const severity = ctx.parsedUrl.searchParams.get("severity");
if (pathname === "/defenseurs/findings") {
const project = parsedUrl.searchParams.get("project");
const category = parsedUrl.searchParams.get("category");
const severity = parsedUrl.searchParams.get("severity");
if (!project) {
res.writeHead(400);
@ -527,10 +338,11 @@ function handleFindings(req, res, ctx) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
return;
}
function handleReportsScans(req, res, ctx) {
const date = ctx.parsedUrl.searchParams.get("date");
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)) {
@ -547,183 +359,16 @@ function handleReportsScans(req, res, ctx) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
return;
}
// Serve one entry per allowlisted workstation, whether or not it ever reported.
// Keeping the neverSeen entries visible is what turns the commissioning window
// (deployed, no heartbeat yet) into "agent not installed" on the dashboard
// instead of a silently missing card.
function handleHostsList(req, res) {
const now = Date.now();
const hosts = [];
for (const id of [...HOSTS_ALLOWED_IDS].sort()) {
const record = readHostSnapshot(id);
if (!record) {
hosts.push({ id, receivedAt: null, ageSeconds: null, online: false, neverSeen: true });
continue;
}
// Freshness is computed server-side from the server-stamped receivedAt —
// the client never gets a say in whether it looks online.
const ageSeconds = Math.max(
0,
Math.floor((now - new Date(record.receivedAt).getTime()) / 1000),
);
hosts.push({
id,
hostname: record.hostname,
uptime: record.uptime,
cpu: record.cpu,
memory: record.memory,
disk: record.disk,
receivedAt: record.receivedAt,
ageSeconds,
online: ageSeconds <= HOSTS_STALE_SECONDS,
});
}
try {
const data = await getHealth();
res.writeHead(200);
res.end(JSON.stringify({ staleAfterSeconds: HOSTS_STALE_SECONDS, hosts }));
}
async function handleHostIngest(req, res, ctx) {
const id = ctx.hostId;
// The id already matched HOST_ROUTE_RE, so 403 here means "well-formed but
// not one of ours" — the only case that reaches this branch.
if (!HOSTS_ALLOWED_IDS.has(id)) {
logRejection(req, 403, "host id not in HOSTS_ALLOWED_IDS", id);
res.writeHead(403);
res.end(JSON.stringify({ error: "Forbidden" }));
return;
}
const raw = await readBody(req, res);
if (raw === null) return; // 413 already answered, or the socket is gone.
let payload;
try {
payload = JSON.parse(raw);
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Bad request: body must be valid JSON" }));
return;
}
const result = sanitizeSnapshot(payload);
if (result.error) {
res.writeHead(400);
res.end(JSON.stringify({ error: `Bad request: ${result.error}` }));
return;
}
// receivedAt is stamped by the SERVER on arrival. A client-supplied timestamp
// would let a stale (or hostile) agent claim to be fresher than it is.
const record = { id, ...result.snapshot, receivedAt: new Date().toISOString() };
try {
writeHostSnapshot(id, record);
res.removeHeader("Content-Type");
res.writeHead(204);
res.end();
res.end(JSON.stringify(data));
} catch (err) {
console.error(`[hosts] failed to persist snapshot for ${id}:`, err.message);
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error" }));
}
}
// --- Routing ----------------------------------------------------------------
// Route descriptors. `tokenKind` is the ONLY thing that varies between the read
// routes and the ingestion route: authentication itself happens once, in the
// single gate below. Adding a route here therefore cannot accidentally ship
// unauthenticated — which is exactly what a per-branch check invites.
const ROUTES = [
{ method: "GET", path: "/health", tokenKind: "read", handle: handleHealth },
{ method: "GET", path: "/defenseurs", tokenKind: "read", handle: handleDefenseurs },
{ method: "GET", path: "/defenseurs/findings", tokenKind: "read", handle: handleFindings },
{ method: "GET", path: "/reports/scans", tokenKind: "read", handle: handleReportsScans },
{ method: "GET", path: "/hosts", tokenKind: "read", handle: handleHostsList },
{ method: "POST", pattern: HOST_ROUTE_RE, tokenKind: "ingest", handle: handleHostIngest },
];
// Deny by default: no descriptor match -> null -> 404.
function resolveRoute(method, pathname) {
for (const route of ROUTES) {
if (route.method !== method) continue;
if (route.path !== undefined) {
if (route.path === pathname) return { route, hostId: null };
continue;
}
const match = route.pattern.exec(pathname);
if (match) return { route, hostId: match[1] };
}
return null;
}
// THE single authentication checkpoint. Returns null when the caller may
// proceed, otherwise the rejection to write. Only the expected token varies
// per descriptor.
function checkAuth(req, route) {
const isIngest = route.tokenKind === "ingest";
const expected = isIngest ? HOSTS_INGEST_TOKEN : TOKEN;
if (!expected) {
return isIngest
? { status: 503, body: { error: "HOSTS_INGEST_TOKEN not configured" }, reason: "ingest token not configured" }
: { status: 401, body: { error: "HEALTH_TOKEN not configured" }, reason: "health token not configured" };
}
const header = req.headers["authorization"];
const ok = isIngest
? typeof header === "string" &&
header.startsWith("Bearer ") &&
tokenMatches(header.slice("Bearer ".length), expected)
: header === `Bearer ${expected}`;
if (!ok) {
return { status: 401, body: { error: "Unauthorized" }, reason: "missing or invalid bearer token" };
}
return null;
}
async function handler(req, res) {
res.setHeader("Content-Type", "application/json");
// 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;
// ORDER IS PART OF THE CONTRACT: routing resolves FIRST, so an unknown path
// or a wrong method answers 404 — not 401 — exactly as before this route
// table existed. Two tests in __tests__/auth.test.js pin that ordering.
const resolved = resolveRoute(req.method, pathname);
if (!resolved) {
res.writeHead(404);
res.end(JSON.stringify({ error: "Not found" }));
return;
}
const rejection = checkAuth(req, resolved.route);
if (rejection) {
logRejection(req, rejection.status, rejection.reason, resolved.hostId);
res.writeHead(rejection.status);
res.end(JSON.stringify(rejection.body));
return;
}
try {
await resolved.route.handle(req, res, { parsedUrl, hostId: resolved.hostId });
} catch (err) {
// Safety net: every handler already owns its error paths, this only catches
// what none of them anticipated.
console.error(`[handler] unhandled error on ${safeLogValue(pathname, 120)}:`, err.message);
if (!res.headersSent) {
res.writeHead(500);
res.end(JSON.stringify({ error: "Internal error" }));
}
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
}
}
@ -734,12 +379,4 @@ if (require.main === module) {
});
}
module.exports = {
handler,
allowedSeverities,
findLatestReportForAgent,
tokenMatches,
sanitizeSnapshot,
HOST_ID_RE,
HOST_ROUTE_RE,
};
module.exports = { handler, allowedSeverities, findLatestReportForAgent };

View file

@ -1,84 +0,0 @@
const os = require("node:os");
const { execSync } = require("node:child_process");
const { setTimeout: delay } = require("node:timers/promises");
// Host metrics collection (CPU / memory / disk), extracted from index.js so the
// same code can be reused by the local workstation agent. Pure host probing:
// no HTTP, no config, no side effects — the caller owns the response shape.
function readProcStat() {
try {
const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim();
const parts = line.split(/\s+/).slice(1).map(Number);
const idle = parts[3] + parts[4];
const total = parts.reduce((a, b) => a + b, 0);
return { idle, total };
} catch {
return null;
}
}
async function getCpuPercent() {
const t1 = readProcStat();
if (!t1) return 0;
const { idle: idle1, total: total1 } = t1;
// Sample over 500ms without blocking the event loop, so other async work
// (e.g. the Logto healthcheck) can run concurrently.
await delay(500);
const t2 = readProcStat();
if (!t2) return 0;
const dIdle = t2.idle - idle1;
const dTotal = t2.total - total1;
if (dTotal === 0) return 0;
return Math.round((1 - dIdle / dTotal) * 100);
}
function getDisk() {
try {
// Alpine df doesn't support --output, use standard POSIX format
const out = execSync("df -k /", { encoding: "utf-8" });
const parts = out.trim().split("\n")[1].trim().split(/\s+/);
// df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted
const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1);
const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1);
const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1);
const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0;
return { totalGB, usedGB, freeGB, usagePercent };
} catch {
return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 };
}
}
// Collect CPU, memory and disk into the `{ cpu, memory, disk }` slice of the
// /health payload. Awaiting getCpuPercent() first is deliberate: the 500ms
// sample is the only await here, so a caller running this inside a
// Promise.all() keeps its other work (the Logto check) fully concurrent.
// Everything else is cheap and synchronous.
async function collectMetrics() {
const cpus = os.cpus();
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const cpuUsagePercent = await getCpuPercent();
return {
cpu: {
model: cpus[0]?.model?.trim() || "unknown",
cores: cpus.length,
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
usagePercent: cpuUsagePercent,
},
memory: {
totalGB: +(totalMem / 1e9).toFixed(1),
usedGB: +(usedMem / 1e9).toFixed(1),
freeGB: +(freeMem / 1e9).toFixed(1),
usagePercent: Math.round((usedMem / totalMem) * 100),
},
disk: getDisk(),
};
}
module.exports = { getCpuPercent, getDisk, collectMetrics };