// SPDX-License-Identifier: GPL-3.0-or-later /** * Local web dashboard. `doctor --web` serves a dark, card-based report in the * browser at 127.0.0.1. Zero dependencies: plain node:http + inline HTML/CSS/JS. */ import http from "node:http"; import { readFileSync } from "node:fs"; import { atomicWrite } from "./fsx.js"; import { exec } from "node:child_process"; import { dirname } from "node:path"; import { addIgnore, addIgnoreCode, removeIgnore, removeIgnoreCode } from "./ignore.js"; import { loadConfig, configFile } from "./config.js"; import { DEFAULT_THRESHOLDS, coerceThreshold } from "./thresholds.js"; import { timerStatus } from "./units.js"; import { clearHistory } from "./history.js"; import { shq } from "./utils.js"; /** * Start the dashboard. `collect` must return { system, findings, generatedAt }. * `render` post-processes the report before it is served at /api/report — pass * one to make the endpoint emit the same versioned envelope as --json. * `quiet` suppresses the URL banner on stdout — needed when embedding the * server in tests, where stdout must stay a clean TAP stream. */ // The only hostnames the dashboard accepts in the Host header. Binding to // 127.0.0.1 alone does not stop a DNS-rebinding attack: a page at // http://evil.example (resolving to 127.0.0.1) would otherwise be served with // an attacker-chosen Host header and could read the report from the victim's // browser. Rejecting non-loopback Host headers closes that hole. const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]); /** "127.0.0.1:43901" / "[::1]:43901" → loopback? Missing Host → not allowed. */ function isLoopbackHost(hostHeader) { if (!hostHeader) return false; const h = String(hostHeader).trim().toLowerCase(); const name = h.startsWith("[") ? h.slice(1, h.indexOf("]")) : h.replace(/:\d+$/, ""); return LOOPBACK_HOSTS.has(name); } /** * Browsers always send Origin on POST requests; curl/scripts do not. A POST * whose Origin names a non-loopback site is a cross-site request forged * against the config-writing endpoints (/api/ignore, /api/thresholds) and is * rejected. No Origin header means a non-browser client — allowed. */ function isLoopbackOrigin(origin) { try { const u = new URL(origin); if (u.protocol !== "http:" && u.protocol !== "https:") return false; return LOOPBACK_HOSTS.has(u.hostname.replace(/^\[|\]$/g, "")); } catch { return false; } } export async function startWeb({ collect, history = () => [], checkList = async () => [], schedule = () => timerStatus(), port = 43901, open = true, quiet = false, render = (d) => d, reportTtlMs = 10000 }) { // A short report cache with single-flight. GETs need no Origin, so without // this any web page could and force // a full scan per request. Concurrent/repeated hits share one scan; the // dashboard's explicit "Re-run" sends ?refresh=1 to bypass the TTL. const REPORT_TTL_MS = reportTtlMs; let reportCache = { at: 0, body: null }; let reportInflight = null; // Single-flight a non-saving scan. It always resolves to the body string, so // a blocking request that arrives while a background scan is in flight gets // that scan's body instead of `undefined` (which reached res.end() as an // empty response: "Unexpected end of JSON input" in the dashboard). const startScan = () => { if (reportInflight) return reportInflight; reportInflight = (async () => { const data = await collect(false); const out = render(data); const body = typeof out === "string" ? out : JSON.stringify(out); reportCache = { at: Date.now(), body }; return body; })().finally(() => { reportInflight = null; }); return reportInflight; }; const server = http.createServer(async (req, res) => { if (!isLoopbackHost(req.headers.host)) { res.writeHead(403, { "Content-Type": "text/plain" }); res.end("Forbidden — the dashboard answers to loopback Host headers only"); return; } if (req.method !== "GET" && req.method !== "HEAD" && req.headers.origin && !isLoopbackOrigin(req.headers.origin)) { res.writeHead(403, { "Content-Type": "text/plain" }); res.end("Forbidden — cross-origin requests are not accepted"); return; } const url = new URL(req.url, "http://localhost"); if (url.pathname === "/api/report") { try { const refresh = url.searchParams.get("refresh") === "1"; // save=1 comes from an explicit Re-run; it must record history and // therefore never be served from (or stored as) a plain poll cache. const save = url.searchParams.get("save") === "1"; const hasCache = typeof reportCache.body === "string"; const fresh = hasCache && Date.now() - reportCache.at < REPORT_TTL_MS; let body; if (!refresh && !save && fresh) { body = reportCache.body; } else if (save) { // An explicit Re-run records history and must never be satisfied by // an in-flight background poll, which does not save: run it on its // own, then refresh the cache. const data = await collect(true); const out = render(data); body = typeof out === "string" ? out : JSON.stringify(out); reportCache = { at: Date.now(), body }; } else if (!refresh && hasCache) { // Stale-while-revalidate: a background poll answers instantly with // the last report and regenerates behind it, so the UI never waits on // a full scan. The next poll picks up the new report. body = reportCache.body; startScan().catch(() => {}); } else { // First paint or an explicit non-saving refresh: block on a scan. body = await startScan(); } res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(body); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } return; } if (url.pathname === "/api/history" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify({ runs: history() })); return; } if (url.pathname === "/api/checks" && req.method === "GET") { try { const checks = await checkList(); res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify({ checks })); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } return; } // Read-only schedule state for the dashboard's "Daily check" strip: // whether the user timer is installed/enabled/active. No actions here — // installing stays a CLI decision (linux-doctor --install-timer). if (url.pathname === "/api/schedule" && req.method === "GET") { try { res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify({ schedule: schedule() })); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } return; } if (url.pathname === "/api/ignore" && req.method === "GET") { const cfg = loadConfig(); res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify({ patterns: Array.isArray(cfg.ignore) ? cfg.ignore : [], codes: Array.isArray(cfg.ignoreCodes) ? cfg.ignoreCodes : [], path: configFile(), })); return; } if (url.pathname === "/api/history/clear" && req.method === "POST") { const ok = clearHistory(); res.writeHead(ok ? 200 : 500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok })); return; } if (url.pathname === "/api/ignore" && req.method === "POST") { let body = ""; for await (const chunk of req) body += chunk; try { const { pattern, code, remove } = JSON.parse(body || "{}"); const ok = code ? (remove ? removeIgnoreCode(code) : addIgnoreCode(code)) : (remove ? removeIgnore(pattern) : addIgnore(pattern)); res.writeHead(ok ? 200 : 400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok })); } catch { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false })); } return; } if (url.pathname === "/api/thresholds" && req.method === "GET") { try { const cfg = loadConfig(); const thresholds = { ...DEFAULT_THRESHOLDS, ...(cfg.thresholds || {}) }; res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); res.end(JSON.stringify({ thresholds, defaults: DEFAULT_THRESHOLDS })); } catch (err) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } return; } if (url.pathname === "/api/thresholds" && req.method === "POST") { let body = ""; for await (const chunk of req) body += chunk; try { const incoming = JSON.parse(body || "{}").thresholds || JSON.parse(body || "{}"); const cfg = loadConfig(); const next = { ...cfg, thresholds: { ...(cfg.thresholds || {}), ...incoming } }; // only allow known keys; drop non-numeric values (e.g. "90%") const clean = {}; for (const k of Object.keys(DEFAULT_THRESHOLDS)) if (k in next.thresholds) { const v = coerceThreshold(next.thresholds[k]); if (v !== null) clean[k] = v; } next.thresholds = clean; const file = configFile(); if (!atomicWrite(file, JSON.stringify(next, null, 2) + "\n")) throw new Error("could not write " + file); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, thresholds: clean })); } catch (err) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: false, error: err.message })); } return; } if (url.pathname === "/" || url.pathname === "/index.html") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(PAGE); return; } res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not found"); }); // The default port (43901) gives the dashboard a stable, predictable URL — // it survives across runs and across browsers. If it is already taken (a // second instance), fall back to a random free port instead of crashing. const listen = (p) => new Promise((resolve, reject) => { server.once("error", reject); server.listen(p, "127.0.0.1", resolve); }); try { await listen(port); } catch { await listen(0); } const url = `http://127.0.0.1:${server.address().port}`; if (open) { try { const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open"; exec(`${opener} ${shq(url)} >/dev/null 2>&1 &`, () => {}); } catch { /* browser may not exist; the printed URL is enough */ } } if (!quiet) console.log(`🩺 Linux Doctor dashboard: ${url} (Ctrl+C to stop)`); return server; } const PAGE = readFileSync(new URL("../src-gui/index.html", import.meta.url), "utf8");