fix(hosts): require 3 loadAvg values and sweep orphan temp files (#25)
Follow-ups from the review of the workstation monitoring stack (#18-#23): - loadAvg must hold exactly 3 finite numbers (1/5/15-min averages). The admin card joins them, so a shorter array rendered a partial line and a longer one was silently truncated; both are now a 400, and a stored snapshot off that format degrades to neverSeen on read. - sweepStaleTempFiles() runs at startup, before listen, and removes the `.<id>.<pid>.<ms>.tmp` files a process killed mid-write leaves behind, once older than an hour. Exact name format only, regular files only (lstat), best effort: a missing HOSTS_DIR stays silent, other failures are logged and never stop the server. - Document that cleanString keeps control characters on purpose (safety rests on the consumer escaping at render time), and that frozenRequest does not freeze `new Date()`, which stamps receivedAt on POST. Fixes #25 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
156ca818ca
commit
523d90bf7c
5 changed files with 210 additions and 11 deletions
|
|
@ -60,12 +60,12 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|||
## Tests
|
||||
|
||||
- **CI** : `.forgejo/workflows/ci.yml` (issue #24), sur toute PR (pas de filtre `branches:`, pour que les maillons d'une pile soient batis) et sur `push` vers `main`. Un seul job en `container: node:22-alpine`, la base du Dockerfile : `npm ci`, `npm test`, `bash test-curl.sh`, puis `sh scripts/boot-from-copy.sh`. Verdict a lire sur le statut de commit (`GET /repos/maximus/vps-health-api/commits/<sha>/status`), l'API Actions repondant 404 sur cette instance.
|
||||
- `npm test` (vitest) — 313 cas
|
||||
- `npm test` (vitest) — 329 cas
|
||||
- `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu)
|
||||
- `__tests__/state.test.js` — `/defenseurs/state` + non-regression `/defenseurs` apres extraction de `serveJsonFile()` (9 cas : corps identique octet pour octet sur un JSON non canonique, no_data, isolation des deux chemins, 401 sans token / mauvais token / token d'ingestion, 404 sur POST)
|
||||
- `__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 (67 cas : matrice route x mode d'echec, en-tetes malformes (dont le separateur `Bearer` autre qu'un espace), quasi-rates du token sur les 6 routes de lecture, fail-closed, **2 cas `404 (not 401)` qui figent l'ordre routage -> auth**, **un espion sur `timingSafeEqual`** qui exige deux empreintes de 32 octets a chaque requete, et **des epingles sur le source** : aucune egalite contre une chaine `Bearer`, un seul `tokenMatches()` et un seul `return null` dans `checkAuth()`, egalites limitees a une liste blanche, `tokenMatches()` qui hache les deux cotes sans test de longueur)
|
||||
- `__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__/hosts.test.js` — matrice exhaustive des endpoints postes (171 cas : regex de route comme controle anti-traversee, 401 avant 403, cloisonnement des deux tokens, 503 fail-closed, reconstruction par liste blanche, `loadAvg` a exactement 3 elements, 413, `neverSeen`, fraicheur serveur sous horloge figee, degradation de lecture, **balayage des `.tmp` orphelins** au demarrage, prouve sur le vrai point d'entree)
|
||||
- `__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**)
|
||||
- `__tests__/ci.test.js` — epingles du workflow (12 cas : image du `container:` identique au `FROM` du Dockerfile, les quatre etapes presentes, aucun filtre `branches:` sous `pull_request`, `push` limite a `main`, ni `defaults:` ni `working-directory` sur une etape `uses:`, **aucun `continue-on-error`** (une etape rouge passerait au vert en silence), ce que le boot check suppose du Dockerfile : `USER node`, `CMD` en forme exec, le **refus d'un port deja occupe** (voisin HTTP reel, script lance en `spawn` asynchrone), et **des `require` en colonne 0 seulement, sans `import()`**, dans chaque `.js` du `COPY`)
|
||||
- Runtime reste 0-dep ; vitest en devDep uniquement
|
||||
|
|
@ -79,8 +79,8 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
|||
- `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.
|
||||
- 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. Un processus tue entre les deux laisse un `.<id>.<pid>.<ms>.tmp` : `sweepStaleTempFiles()` les supprime au demarrage, avant `listen`, s'ils ont plus d'une heure (plus jeunes, un autre conteneur peut etre en train d'ecrire pendant un redeploy). Il ne touche que ce format de nom exact et que des fichiers reguliers (`lstat`) ; changer le nom du fichier temporaire impose de changer `TEMP_FILE_RE`.
|
||||
- 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. `loadAvg` doit avoir exactement 3 nombres (la carte admin les joint par « / ») : un fichier ancien hors format degrade en `neverSeen`. Les chaines sont tronquees, **pas filtrees** (caracteres de controle conserves) : la surete repose sur l'echappement du consommateur, React aujourd'hui.
|
||||
- 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).
|
||||
- `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`.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ answers `404`, never `401`.
|
|||
Body: the `GET /health` payload minus `logto` and `timestamp` — `hostname`,
|
||||
`uptime`, `cpu{model,cores,loadAvg,usagePercent}`,
|
||||
`memory{totalGB,usedGB,freeGB,usagePercent}`, `disk{…same four…}`.
|
||||
`loadAvg` must hold exactly 3 finite numbers (the 1, 5 and 15-minute
|
||||
averages); any other length is a `400`.
|
||||
|
||||
`<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
|
||||
|
|
@ -46,6 +48,13 @@ 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.
|
||||
|
||||
Strings are truncated, not filtered: control characters and markup are kept so
|
||||
a legitimate value is never mangled. Consumers must escape them when rendering
|
||||
(React text nodes do; `dangerouslySetInnerHTML` or an `href` would not).
|
||||
|
||||
A process killed mid-write can leave a `.<id>.<pid>.<ms>.tmp` file behind.
|
||||
Startup removes the ones older than an hour; `GET /hosts` never reads them.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const http = require("node:http");
|
|||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
|
||||
// Coverage matrix for the workstation snapshot surface (issues #13 and #14).
|
||||
//
|
||||
|
|
@ -138,6 +139,12 @@ function writeSnapshotAged(id, ageSeconds, overrides = {}) {
|
|||
// Run a request with Date.now() pinned to FROZEN_NOW. Only Date.now is replaced
|
||||
// (not the whole timer stack), so fetch and the http server keep their real
|
||||
// timeouts; the server reads the clock once, in handleHostsList.
|
||||
//
|
||||
// `new Date()` without an argument does NOT go through Date.now: it reads the
|
||||
// real clock even inside frozenRequest. That includes the receivedAt stamped by
|
||||
// POST /hosts (`new Date().toISOString()`), so a POST made through this helper
|
||||
// is dated with the real time, not FROZEN_NOW. For a controlled receivedAt,
|
||||
// write the file with writeSnapshotAged() instead of posting it.
|
||||
async function frozenRequest(route, options = {}) {
|
||||
const realNow = Date.now;
|
||||
Date.now = () => FROZEN_NOW;
|
||||
|
|
@ -656,6 +663,10 @@ describe("POST /hosts/<id> — field validation matrix", () => {
|
|||
["cpu.loadAvg missing", (p) => delete p.cpu.loadAvg, /cpu\.loadAvg must be an array/],
|
||||
["cpu.loadAvg as a string", (p) => (p.cpu.loadAvg = "0.5"), /cpu\.loadAvg must be an array/],
|
||||
["cpu.loadAvg holding a string", (p) => (p.cpu.loadAvg = [0.5, "x", 0.3]), /cpu\.loadAvg must contain finite numbers/],
|
||||
["cpu.loadAvg empty", (p) => (p.cpu.loadAvg = []), /cpu\.loadAvg must hold exactly 3 finite numbers/],
|
||||
["cpu.loadAvg with 1 entry", (p) => (p.cpu.loadAvg = [0.5]), /cpu\.loadAvg must hold exactly 3 finite numbers/],
|
||||
["cpu.loadAvg with 2 entries", (p) => (p.cpu.loadAvg = [0.5, 0.4]), /cpu\.loadAvg must hold exactly 3 finite numbers/],
|
||||
["cpu.loadAvg with 4 entries", (p) => (p.cpu.loadAvg = [0.5, 0.4, 0.3, 0.2]), /cpu\.loadAvg must hold exactly 3 finite numbers/],
|
||||
["memory missing", (p) => delete p.memory, /memory must be an object/],
|
||||
["memory as an array", (p) => (p.memory = []), /memory must be an object/],
|
||||
["memory.usedGB missing", (p) => delete p.memory.usedGB, /memory\.usedGB must be a finite number/],
|
||||
|
|
@ -699,12 +710,14 @@ describe("POST /hosts/<id> — field validation matrix", () => {
|
|||
expect(JSON.parse(readSnapshotFile("thinkpad")).uptime).toBe(0);
|
||||
});
|
||||
|
||||
test("an empty loadAvg array is accepted", async () => {
|
||||
// The admin card joins the three averages: fewer would render a partial
|
||||
// line, more would be silently dropped. Both are refused (see the matrix).
|
||||
test("a loadAvg of exactly 3 numbers, zeros included, is accepted", async () => {
|
||||
const payload = validSnapshot();
|
||||
payload.cpu.loadAvg = [];
|
||||
payload.cpu.loadAvg = [0, 0, 0];
|
||||
const { status } = await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) });
|
||||
expect(status).toBe(204);
|
||||
expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.loadAvg).toEqual([]);
|
||||
expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.loadAvg).toEqual([0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -760,11 +773,11 @@ describe("POST /hosts/<id> — what actually lands on disk", () => {
|
|||
expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.model.length).toBe(MAX_STRING_LENGTH);
|
||||
});
|
||||
|
||||
test("loadAvg keeps only the first three entries", async () => {
|
||||
test("loadAvg is stored as sent, never truncated", async () => {
|
||||
const payload = validSnapshot();
|
||||
payload.cpu.loadAvg = [1, 2, 3, 4, 5];
|
||||
payload.cpu.loadAvg = [1.25, 0.5, 3];
|
||||
await ingest("/hosts/thinkpad", { body: JSON.stringify(payload) });
|
||||
expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.loadAvg).toEqual([1, 2, 3]);
|
||||
expect(JSON.parse(readSnapshotFile("thinkpad")).cpu.loadAvg).toEqual([1.25, 0.5, 3]);
|
||||
});
|
||||
|
||||
test("__proto__ appears nowhere in the file text", async () => {
|
||||
|
|
@ -1001,6 +1014,17 @@ describe("GET /hosts — listing semantics", () => {
|
|||
"a receivedAt that is not a string",
|
||||
JSON.stringify({ id: "thinkpad", ...validSnapshot(), receivedAt: 1700000000000 }),
|
||||
],
|
||||
[
|
||||
// Written before loadAvg required exactly 3 entries, or by hand: the read
|
||||
// path applies the same contract as ingestion.
|
||||
"a snapshot whose loadAvg is not 3 numbers",
|
||||
JSON.stringify({
|
||||
id: "thinkpad",
|
||||
...validSnapshot(),
|
||||
cpu: { ...validSnapshot().cpu, loadAvg: [1, 2] },
|
||||
receivedAt: new Date().toISOString(),
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
test.each(DEGRADED)("degrades %s to a never-seen entry", async (_label, raw) => {
|
||||
|
|
@ -1076,3 +1100,112 @@ describe("round trip — POST then GET", () => {
|
|||
expect(after.body.hosts[0].ageSeconds).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
// A process killed between writeFileSync and renameSync leaves its temp file in
|
||||
// HOSTS_DIR; the startup sweep removes the stale ones and nothing else.
|
||||
describe("sweepStaleTempFiles", () => {
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const NOW = Date.UTC(2026, 8, 29, 12, 0, 0);
|
||||
|
||||
// Real files with a controlled mtime; the sweep is called with an explicit
|
||||
// `now` instead of freezing the clock.
|
||||
function place(name, ageMs, { dir = false } = {}) {
|
||||
fs.mkdirSync(hostsDir, { recursive: true });
|
||||
const file = path.join(hostsDir, name);
|
||||
if (dir) fs.mkdirSync(file);
|
||||
else fs.writeFileSync(file, "{}");
|
||||
const mtime = new Date(NOW - ageMs);
|
||||
fs.utimesSync(file, mtime, mtime);
|
||||
return file;
|
||||
}
|
||||
|
||||
// beforeEach has just re-required index.js with HOSTS_DIR pointing here.
|
||||
function sweep() {
|
||||
return require("../index.js").sweepStaleTempFiles(NOW);
|
||||
}
|
||||
|
||||
test("removes a temp file older than an hour", () => {
|
||||
const file = place(".thinkpad.4242.1700000000000.tmp", 2 * HOUR_MS);
|
||||
expect(sweep()).toEqual([".thinkpad.4242.1700000000000.tmp"]);
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps a temp file younger than an hour: another writer may own it", () => {
|
||||
const file = place(".thinkpad.4242.1700000000000.tmp", HOUR_MS - 1000);
|
||||
expect(sweep()).toEqual([]);
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["the snapshot itself", "thinkpad.json"],
|
||||
["a temp name without the leading dot", "xthinkpad.4242.1700000000000.tmp"],
|
||||
["a temp name with an uppercase id", ".THINKPAD.4242.1700000000000.tmp"],
|
||||
["a temp name missing the timestamp", ".thinkpad.4242.tmp"],
|
||||
["a temp name with a non-numeric pid", ".thinkpad.abc.1700000000000.tmp"],
|
||||
["an unrelated hidden temp file", ".evil.tmp"],
|
||||
])("never touches %s, however old", (_label, name) => {
|
||||
const file = place(name, 48 * HOUR_MS);
|
||||
expect(sweep()).toEqual([]);
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
});
|
||||
|
||||
// lstat + isFile(): a directory named like a temp file is skipped, not handed
|
||||
// to unlink (which would fail and log). The error spy is what pins the guard:
|
||||
// without it the directory survives too, but through the error path.
|
||||
test("skips a directory named like a temp file without logging an error", () => {
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const dir = place(".thinkpad.4242.1700000000000.tmp", 2 * HOUR_MS, { dir: true });
|
||||
expect(sweep()).toEqual([]);
|
||||
expect(fs.existsSync(dir)).toBe(true);
|
||||
expect(errors).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
errors.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a missing HOSTS_DIR is the normal first boot: no throw, no log", () => {
|
||||
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
expect(fs.existsSync(hostsDir)).toBe(false);
|
||||
expect(sweep()).toEqual([]);
|
||||
expect(errors).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
errors.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// The real entrypoint, not a source pin: the sweep runs synchronously before
|
||||
// listen(), so the stale file is gone by the time the server says it listens.
|
||||
test("runs at startup, before the server listens", async () => {
|
||||
const stale = place(".thinkpad.4242.1700000000000.tmp", 0);
|
||||
const old = new Date(Date.now() - 2 * HOUR_MS);
|
||||
fs.utimesSync(stale, old, old);
|
||||
const fresh = path.join(hostsDir, ".thinkpad.4243.1700000000001.tmp");
|
||||
fs.writeFileSync(fresh, "{}");
|
||||
|
||||
const child = spawn(process.execPath, [path.join(__dirname, "..", "index.js")], {
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: "0",
|
||||
HOSTS_DIR: hostsDir,
|
||||
LOGTO_HEALTH_URL: "http://127.0.0.1:1/",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
let out = "";
|
||||
child.stdout.on("data", (d) => {
|
||||
out += d;
|
||||
if (out.includes("listening")) resolve();
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => reject(new Error(`server exited early (code ${code})`)));
|
||||
});
|
||||
expect(fs.existsSync(stale)).toBe(false);
|
||||
expect(fs.existsSync(fresh)).toBe(true);
|
||||
} finally {
|
||||
child.kill();
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -171,6 +171,9 @@ Exactly the `GET /health` body minus `logto` and `timestamp`:
|
|||
disk: { totalGB, usedGB, freeGB, usagePercent } }
|
||||
```
|
||||
|
||||
`loadAvg` is exactly three numbers (1, 5 and 15 minutes), as `os.loadavg()`
|
||||
returns them; the server answers `400` to any other length.
|
||||
|
||||
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
|
||||
|
|
|
|||
56
index.js
56
index.js
|
|
@ -8,6 +8,7 @@ const {
|
|||
writeFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
lstatSync,
|
||||
} = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { createHash, timingSafeEqual } = require("node:crypto");
|
||||
|
|
@ -312,6 +313,11 @@ function readBody(req, res) {
|
|||
});
|
||||
}
|
||||
|
||||
// Truncates, and nothing else: control characters and markup are kept on
|
||||
// purpose, so a legitimate value (a hostname, a CPU model string) is never
|
||||
// mangled. Safety therefore rests on the consumer escaping at render time. The
|
||||
// admin card renders these as React text nodes, which escape them; rendering
|
||||
// one through dangerouslySetInnerHTML or into an href would not be safe.
|
||||
function cleanString(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.slice(0, MAX_STRING_LENGTH);
|
||||
|
|
@ -345,9 +351,13 @@ function sanitizeCpu(value) {
|
|||
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" };
|
||||
// Exactly the 1, 5 and 15-minute averages. os.loadavg() always returns three
|
||||
// entries, and the admin card joins them with " / ": a shorter array would
|
||||
// render a partial line rather than fail, so it is refused here instead.
|
||||
if (!Array.isArray(value.loadAvg)) return { error: "cpu.loadAvg must be an array" };
|
||||
if (value.loadAvg.length !== 3) return { error: "cpu.loadAvg must hold exactly 3 finite numbers" };
|
||||
const loadAvg = [];
|
||||
for (const entry of value.loadAvg.slice(0, 3)) {
|
||||
for (const entry of value.loadAvg) {
|
||||
const num = cleanNumber(entry);
|
||||
if (num === null) return { error: "cpu.loadAvg must contain finite numbers" };
|
||||
loadAvg.push(num);
|
||||
|
|
@ -391,6 +401,9 @@ function sanitizeSnapshot(payload) {
|
|||
// 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.
|
||||
//
|
||||
// The temp name `.<id>.<pid>.<ms>.tmp` is what sweepStaleTempFiles() matches:
|
||||
// change one, change the other.
|
||||
function writeHostSnapshot(id, record) {
|
||||
mkdirSync(HOSTS_DIR, { recursive: true });
|
||||
const finalPath = path.join(HOSTS_DIR, `${id}.json`);
|
||||
|
|
@ -408,6 +421,45 @@ function writeHostSnapshot(id, record) {
|
|||
}
|
||||
}
|
||||
|
||||
// A process killed between writeFileSync and renameSync (SIGKILL, OOM) leaves
|
||||
// its temp file behind: the catch above never runs. Harmless — GET /hosts only
|
||||
// reads <id>.json — but nothing else would ever remove it, so startup sweeps
|
||||
// the ones older than an hour. Younger ones are left alone: another instance
|
||||
// may still be writing (a rolling redeploy runs two containers on the mount).
|
||||
//
|
||||
// Only the exact temp-name format is touched, and only regular files (lstat,
|
||||
// so a symlink planted on the writable mount is neither followed nor removed).
|
||||
// Best effort throughout: a missing HOSTS_DIR is the normal first boot and
|
||||
// stays silent; any other failure is logged and never stops the server.
|
||||
const TEMP_FILE_RE = new RegExp(`^\\.${HOST_ID_PATTERN}\\.\\d+\\.\\d+\\.tmp$`);
|
||||
const TEMP_MAX_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
function sweepStaleTempFiles(now = Date.now()) {
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(HOSTS_DIR);
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") console.error(`[hosts] temp sweep skipped: ${err.code || err.message}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const removed = [];
|
||||
for (const name of names) {
|
||||
if (!TEMP_FILE_RE.test(name)) continue;
|
||||
const file = path.join(HOSTS_DIR, name);
|
||||
try {
|
||||
const stat = lstatSync(file);
|
||||
if (!stat.isFile() || now - stat.mtimeMs <= TEMP_MAX_AGE_MS) continue;
|
||||
unlinkSync(file);
|
||||
removed.push(name);
|
||||
} catch (err) {
|
||||
console.error(`[hosts] temp sweep could not remove ${name}: ${err.code || err.message}`);
|
||||
}
|
||||
}
|
||||
if (removed.length) console.log(`[hosts] swept ${removed.length} stale temp file(s)`);
|
||||
return removed;
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -739,6 +791,7 @@ async function handler(req, res) {
|
|||
}
|
||||
|
||||
if (require.main === module) {
|
||||
sweepStaleTempFiles();
|
||||
const server = http.createServer(handler);
|
||||
server.listen(PORT, () => {
|
||||
console.log(`vps-health-api listening on :${PORT}`);
|
||||
|
|
@ -751,6 +804,7 @@ module.exports = {
|
|||
findLatestReportForAgent,
|
||||
tokenMatches,
|
||||
sanitizeSnapshot,
|
||||
sweepStaleTempFiles,
|
||||
HOST_ID_RE,
|
||||
HOST_ROUTE_RE,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue