diff --git a/src/dns-service.mjs b/src/dns-service.mjs index d450585..a54c604 100644 --- a/src/dns-service.mjs +++ b/src/dns-service.mjs @@ -130,10 +130,12 @@ export function serviceUnit({ function run(command, args) { return new Promise((resolve) => { const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let out = ""; let err = ""; + child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (err += d)); child.on("error", (error) => resolve({ ok: false, error: error.message })); - child.on("exit", (code) => resolve({ ok: code === 0, error: err.trim() })); + child.on("exit", (code) => resolve({ ok: code === 0, stdout: out, error: err.trim() })); }); } @@ -277,6 +279,38 @@ export function proxyServiceUnit({ return lines.join("\n"); } +/** + * Is the local root the old shape, minted for a list of endings? + * + * moshpit-proxy used to constrain its root by naming what it could certify. + * That root works only for the endings it happened to name, which is why a + * machine could reach `.2600` over HTTPS and not `.hacker` — and why the list + * could never be right, since the registry keeps selling more. + * + * The root it mints now excludes the real internet instead and covers the whole + * namespace. But a machine that ran the old proxy still has the old root on + * disk, and moshpit-proxy will not replace a root that already exists — so + * without this, upgrading leaves the narrow root in place and `.hacker` keeps + * failing with nothing to explain why. + * + * A permitted DNS subtree is the tell. The new root has none by design. + */ +export async function rootIsNarrow({ + home = operatorHome(), + exec = run, + exists = existsSync, +} = {}) { + const file = join(home, ".moshpit", "ca", "ca.crt"); + if (!exists(file)) return { narrow: false, reason: "no root yet", file }; + const described = await exec("openssl", ["x509", "-noout", "-text", "-in", file]); + // Unreadable is not narrow. Deleting a root because openssl was missing would + // throw away a working setup to fix a problem nobody had. + if (!described.ok) return { narrow: false, reason: "could not read it", file }; + return described.stdout && /Permitted:/i.test(described.stdout) + ? { narrow: true, reason: "constrained to a list of endings", file } + : { narrow: false, reason: "already covers the namespace", file }; +} + /** Where moshpit-proxy's installer puts its wrapper, if it ran. */ export function proxyWrapperPath({ home = operatorHome(), exists = existsSync } = {}) { const candidate = join(home, ".local/bin/moshpit-proxy"); @@ -304,6 +338,8 @@ export async function ensureProxyService({ tlds = [], port = 443, exec = run, + narrowRoot = rootIsNarrow, + paths = proxyServicePaths, read = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"), listening = defaultPortHeld, waitMs = 30000, @@ -311,7 +347,7 @@ export async function ensureProxyService({ const wrapper = proxyWrapperPath({ home }); if (!wrapper) return { ok: false, reason: "not-installed", steps: [] }; - const { path, systemctl } = proxyServicePaths(); + const { path, systemctl } = paths(); const unit = proxyServiceUnit({ wrapper, nodeDir, home, user, port, tlds }); // Read before writing so the manifest can put back whatever was here — which // is usually nothing, and "nothing" has to be recorded as precisely as @@ -319,6 +355,22 @@ export async function ensureProxyService({ const before = await read(path).catch(() => null); const steps = []; + + // A root minted by the old proxy names the endings it may certify, and + // moshpit-proxy will not replace a root that already exists. Left alone, an + // upgraded machine keeps the narrow root and `.hacker` keeps failing — so it + // goes, and the proxy mints the current shape on its next start. + // + // Safe to remove: it is a local root, regenerated in seconds, and `dns enable` + // installs the replacement into the trust stores in the same run. Removing it + // without that would be the destructive half on its own, which is why this + // lives here and not in the installer. + const narrow = await narrowRoot({ home, exec }); + if (narrow.narrow) { + await rm(join(home, ".moshpit", "ca"), { recursive: true, force: true }).catch(() => {}); + steps.push({ step: `reminted the local root — the old one was ${narrow.reason}`, ok: true }); + } + try { await mkdir(dirname(path), { recursive: true }); await writeFile(path, unit); @@ -328,7 +380,14 @@ export async function ensureProxyService({ } const [cmd, ...flags] = systemctl; - for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", PROXY_UNIT_NAME]]) { + // `enable --now` starts a stopped unit and does nothing to a running one, so + // an upgrade would leave the previous process — and the previous root, and + // the previous namespace — in place. Restart is what makes an upgrade take. + for (const args of [ + [...flags, "daemon-reload"], + [...flags, "enable", PROXY_UNIT_NAME], + [...flags, "restart", PROXY_UNIT_NAME], + ]) { const result = await exec(cmd, args); steps.push({ step: `${cmd} ${args.join(" ")}`, ok: result.ok, error: result.error }); if (!result.ok) return { ok: false, reason: "systemctl-failed", before, steps, path, unit }; diff --git a/test/dns-service.test.mjs b/test/dns-service.test.mjs index 1e53d7c..1e0e46d 100644 --- a/test/dns-service.test.mjs +++ b/test/dns-service.test.mjs @@ -19,7 +19,7 @@ // that looks plausible and dies at 203/EXEC with nothing useful in the journal. import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -27,6 +27,7 @@ import { join } from "node:path"; import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME } from "../src/dns-service.mjs"; import { proxyProbeFromArgs, captureRestorePoint } from "../src/dns.mjs"; import { proxyServiceUnit, proxyServicePaths, PROXY_UNIT_NAME } from "../src/dns-service.mjs"; +import { rootIsNarrow, ensureProxyService } from "../src/dns-service.mjs"; const scratch = () => mkdtemp(join(tmpdir(), "moshcode-service-")); const unit = (opts = {}) => serviceUnit({ entry: "/opt/moshcode/bin/moshcode.mjs", port: 5354, execPath: "/opt/node/bin/node", ...opts }); @@ -251,3 +252,98 @@ test("the restore point records the units, including that they were absent", asy assert.equal(byPath["/etc/systemd/system/moshpit-proxy.service"], "theirs, from before\n"); assert.equal(byPath["/home/x/.config/systemd/user/moshcode-dns.service"], null); }); + +/** A scratch home with moshpit-proxy installed in it, as a real machine has. */ +async function homeWithProxy() { + const home = await scratch(); + await mkdir(join(home, ".local/bin"), { recursive: true }); + await writeFile(join(home, ".local/bin/moshpit-proxy"), "#!/bin/sh\nexec node x\n"); + return home; +} + +/* ---------------------------------------------- the root that stopped scaling */ + +// moshpit-proxy used to constrain its root by naming the endings it could +// certify, and it will not replace a root that already exists. So an upgraded +// machine keeps the narrow root, `.hacker` keeps failing, and nothing says why. +// `dns enable` has to notice and remint, or the upgrade is a no-op for anyone +// who ran the old proxy. + +const described = (stdout) => async () => ({ ok: true, stdout }); + +test("a root naming endings is narrow — it only works for what it named", async () => { + const r = await rootIsNarrow({ + home: "/anywhere", + exists: () => true, + exec: described("X509v3 Name Constraints: critical\n Permitted:\n DNS:.hacker\n"), + }); + assert.equal(r.narrow, true); +}); + +test("a root excluding the internet is current — it covers everything", async () => { + const r = await rootIsNarrow({ + home: "/anywhere", + exists: () => true, + exec: described("X509v3 Name Constraints: critical\n Excluded:\n DNS:.com\n"), + }); + assert.equal(r.narrow, false); + assert.match(r.reason, /covers the namespace/); +}); + +test("no root is not a narrow root", async () => { + const r = await rootIsNarrow({ home: "/anywhere", exists: () => false }); + assert.equal(r.narrow, false); +}); + +test("an unreadable root is left alone rather than deleted", async () => { + // Throwing away a working root because openssl was missing would break a + // machine to fix a problem it did not have. + const r = await rootIsNarrow({ + home: "/anywhere", + exists: () => true, + exec: async () => ({ ok: false, error: "openssl: not found" }), + }); + assert.equal(r.narrow, false); + assert.match(r.reason, /could not read/); +}); + +/* ------------------------------------------------- an upgrade that takes */ + +test("the proxy is restarted, not merely enabled", async () => { + // `enable --now` starts a stopped unit and does nothing to a running one, so + // an upgrade would leave the old process serving the old root from the old + // namespace — and report success. + const home = await homeWithProxy(); + const calls = []; + await ensureProxyService({ + home, + user: "x", + nodeDir: "/opt/node/bin", + listening: async () => true, + paths: () => ({ path: join(home, "moshpit-proxy.service"), systemctl: ["systemctl"], scope: "system" }), + narrowRoot: async () => ({ narrow: false, reason: "current" }), + exec: async (cmd, args) => { calls.push(args.join(" ")); return { ok: true, error: "" }; }, + }).catch(() => {}); + + assert.ok(calls.some((c) => c.startsWith("restart")), `expected a restart, got ${JSON.stringify(calls)}`); +}); + +test("a narrow root is reminted as part of bringing the proxy up", async () => { + const home = await homeWithProxy(); + let removed = false; + const result = await ensureProxyService({ + home, + user: "x", + nodeDir: "/opt/node/bin", + listening: async () => true, + paths: () => ({ path: join(home, "moshpit-proxy.service"), systemctl: ["systemctl"], scope: "system" }), + narrowRoot: async () => { removed = true; return { narrow: true, reason: "constrained to a list of endings" }; }, + exec: async () => ({ ok: true, error: "" }), + }).catch(() => null); + + assert.ok(removed, "the root's shape is checked before the proxy is started"); + assert.ok( + (result?.steps || []).some((s) => /reminted the local root/.test(s.step)), + "and the remint is reported, because it invalidates the trust the machine already has", + ); +});