From 23398cb6fd2bd1ea75df6bd98f8b05cfd9300e41 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 23:23:37 +0000 Subject: [PATCH] feat(dns): remint a stale root, and restart the proxy on upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moshcode upgrade` followed by `moshcode dns enable` was supposed to be the whole of it. On a machine that had run the old proxy it was not, and the two reasons were invisible. 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 kept the narrow root: it works for the endings someone had once configured and fails for everything else, which is why `.2600` could reach HTTPS while `.hacker` could not. Nothing reported it, because from every angle the setup looked complete — proxy installed, service running, names resolving. `dns enable` now reads the root before starting the proxy, and removes it when it carries a permitted DNS subtree. That is the tell: the root minted today has none, because it excludes the real internet instead of enumerating Moshpit. The proxy mints the current shape on its next start and `dns enable` installs it into the trust stores in the same run — which is why this belongs here and not in the installer, where removing a root would be the destructive half on its own. An unreadable root is left alone. Throwing one away because openssl was missing would break a working machine to fix a problem it did not have. The second reason: `systemctl enable --now` starts a stopped unit and does nothing at all to a running one. An upgrade therefore left the previous process alive, still serving the previous root under the previous namespace, and reported success. It enables and restarts now, which is what makes an upgrade take effect. The remint also moved ahead of writing the unit file. The root's shape has nothing to do with whether /etc is writable, and sequencing it behind that meant a machine that could not write the unit silently kept its stale root too. Six tests. The detector is checked against a root naming endings, a root excluding the internet, no root, and an unreadable one; the lifecycle is checked for issuing a restart rather than only an enable, and for reminting before it touches anything else. `proxyServicePaths` is injectable so none of that writes to /etc. Suite: 2760 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ThnQwoieWt8VR6N7gtgnhp --- src/dns-service.mjs | 65 ++++++++++++++++++++++++-- test/dns-service.test.mjs | 98 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 4 deletions(-) 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", + ); +});