From 4ddda5f82a78bdbffd9602f05c57f98e818ea7ed Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 16:00:33 +0000 Subject: [PATCH] feat(dns): enable supervises the proxy, disable takes it away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two commands were supposed to be the whole of it — install/upgrade, then `dns enable` — and getting a desktop working took eleven manual steps instead. The largest missing piece: moshpit-proxy ships no unit of its own, so on every machine that installed it, it sat there installed, trusted and never started. Indistinguishable from absent — nothing on 443, no certificate, and `dns enable` correctly reporting no proxy on a box that had one. `dns enable` now writes and starts that unit before it goes looking for a proxy, and waits for 443 to actually be held rather than trusting Type=simple's idea of "active". The unit runs the wrapper with a PATH containing the interpreter running this code — the mistake that has now bitten three times in this codebase, made deliberately once here — drops to the operator's account so the local root in ~/.moshpit is reachable, and is granted CAP_NET_BIND_SERVICE rather than running as root. Validated against the hand-written unit on the one machine where this has been working for months, which reached the same shape. Non-fatal in every direction. A machine without a working proxy resolves Moshpit names and cannot verify them; a machine whose DNS was refused because an optional component would not start is worse. `dns disable` stops and removes it, gated on the restore point rather than on what happens to be in /etc — a proxy unit this tool never installed is somebody else's, and stopping it for sharing a filename is exactly the guessing the manifest exists to prevent. `captureRestorePoint` gains `extraPaths` so both units are recorded the same way as everything else: prior content, or null for "was not here". Null is the load-bearing value — it is what removes a unit this run created and what preserves one that was already there. The trust anchor needed nothing: `applyTrust` has always installed the local root into /usr/local/share/ca-certificates and `applyUntrust` removes it. That step simply never ran, because every enable died before reaching it. Suite: 2741 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ThnQwoieWt8VR6N7gtgnhp --- src/dns-service.mjs | 194 ++++++++++++++++++++++++++++++++++++++ src/dns.mjs | 67 ++++++++++++- test/dns-service.test.mjs | 79 +++++++++++++++- 3 files changed, 338 insertions(+), 2 deletions(-) diff --git a/src/dns-service.mjs b/src/dns-service.mjs index 224c322..efca84a 100644 --- a/src/dns-service.mjs +++ b/src/dns-service.mjs @@ -30,6 +30,7 @@ // is guessed and nothing depends on PATH. import { spawn } from "node:child_process"; import { mkdir, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; @@ -171,3 +172,196 @@ export async function removeService({ system = false, home = homedir(), exec = r steps.push({ step: `${cmd} ${[...flags, "daemon-reload"].join(" ")}`, ok: reload.ok, error: reload.error }); return { ok: true, path, scope, steps }; } + +/* --------------------------------------------------- the pinned-TLS proxy */ + +/** + * The other half of a machine that can actually reach Moshpit names. + * + * The bridge makes names resolve. It cannot make them verifiable: no CA will + * ever sign for `.eggs`, so without a proxy every name answers its origin's own + * self-signed leaf and a stock client refuses it. moshpit-proxy terminates TLS + * with a local root instead — one root for every ending, rather than trusting + * certificates one name at a time. + * + * moshpit-proxy ships no unit of its own, so nothing ever started it. It was + * installed, trusted, and idle, which reads exactly like "not installed" from + * every direction: nothing on 443, no certificate, and `dns enable` correctly + * reporting no proxy on a machine that had one. + */ +export const PROXY_UNIT_NAME = "moshpit-proxy.service"; + +/** + * A system unit, unlike the bridge's. + * + * 443 is privileged and the proxy must have it: DNS carries an address and has + * nowhere to put a port, so a browser sent to a Moshpit name goes to 443 or + * nowhere. A user unit cannot bind it. So this is a system unit that drops to + * the operator's account and is granted the one capability it needs — rather + * than running as root, which it has no other use for. + */ +export function proxyServicePaths() { + return { path: join("/etc/systemd/system", PROXY_UNIT_NAME), systemctl: ["systemctl"], scope: "system" }; +} + +/** + * The unit text, pinned to this install. + * + * `ExecStart` runs moshpit-proxy's own wrapper rather than reaching past it to + * an entry script, so a change to that project's layout does not silently break + * this. The wrapper execs `node`, which systemd's PATH does not have on a mise, + * nvm or asdf box — so PATH is set from the interpreter running this code, + * which is by definition one that works. That mistake has now been made three + * times in this codebase; it is made here on purpose and only once. + */ +export function proxyServiceUnit({ + wrapper, + nodeDir, + home = homedir(), + user = process.env.SUDO_USER || process.env.USER || process.env.LOGNAME, + port = 443, + tlds = [], +} = {}) { + if (!wrapper) throw new Error("proxyServiceUnit needs the moshpit-proxy wrapper path"); + if (!user) throw new Error("proxyServiceUnit needs the account the proxy runs as"); + + const path = [nodeDir, "/usr/local/bin", "/usr/bin", "/bin"].filter(Boolean).join(":"); + // `.moshpit` under the operator's home is where the local root already lives, + // put there by moshpit-proxy's own installer. Naming it explicitly keeps the + // service off /root/.moshpit, which is where a system unit would otherwise + // look and where there is nothing. + const dir = join(home, ".moshpit"); + + const lines = [ + "# Generated by `moshcode dns enable`. Regenerate rather than editing:", + "# the paths below are this install's, and a node or proxy that moves leaves", + "# a unit that fails at 203/EXEC with nothing else to say.", + "[Unit]", + "Description=Moshpit pinned-TLS proxy", + "Documentation=https://github.com/profullstack/moshpit-proxy", + "After=network-online.target", + "Wants=network-online.target", + "", + "[Service]", + "Type=simple", + `User=${user}`, + `Environment=PATH=${path}`, + `Environment=MOSHPIT_PROXY_PORT=${port}`, + `Environment=MOSHPIT_PROXY_DIR=${dir}`, + ]; + // Only the endings it is asked to serve. Left unset it defaults to `.moshpit` + // alone, which is why a proxy can be running, healthy, and unable to present + // a certificate for the name someone is actually trying to reach. + if (tlds.length) lines.push(`Environment=MOSHPIT_PROXY_TLDS=${tlds.join(",")}`); + + lines.push( + `ExecStart=${wrapper}`, + "Restart=always", + "RestartSec=2", + // The whole reason this is a system unit. Granted rather than inherited: + // the proxy runs as the operator and needs exactly one privilege. + "AmbientCapabilities=CAP_NET_BIND_SERVICE", + "CapabilityBoundingSet=CAP_NET_BIND_SERVICE", + "NoNewPrivileges=yes", + "PrivateTmp=yes", + "", + "[Install]", + "WantedBy=multi-user.target", + "", + ); + return lines.join("\n"); +} + +/** Where moshpit-proxy's installer puts its wrapper, if it ran. */ +export function proxyWrapperPath({ home = homedir(), exists = existsSync } = {}) { + const candidate = join(home, ".local/bin/moshpit-proxy"); + return exists(candidate) ? candidate : null; +} + +/** + * Put the proxy under supervision, and wait for it to actually hold 443. + * + * Returns a plain report rather than throwing, and every caller treats a + * failure as "no proxy" rather than as a failed run. A machine without a + * working proxy resolves Moshpit names and cannot verify them, which is worse + * than it sounds but is still enormously better than a machine whose DNS was + * refused because an optional component would not start. + * + * `listening` is asked rather than assumed: `Type=simple` reports active the + * moment it forks, so "started" and "serving" are different questions and this + * has to answer the second one. The proxy fetches a registry pin before it can + * answer, so the wait is generous. + */ +export async function ensureProxyService({ + home = homedir(), + user = process.env.SUDO_USER || process.env.USER || process.env.LOGNAME, + nodeDir = dirname(process.execPath), + tlds = [], + port = 443, + exec = run, + read = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"), + listening = defaultPortHeld, + waitMs = 30000, +} = {}) { + const wrapper = proxyWrapperPath({ home }); + if (!wrapper) return { ok: false, reason: "not-installed", steps: [] }; + + const { path, systemctl } = proxyServicePaths(); + 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 + // content would be, or disable leaves a unit nobody asked for. + const before = await read(path).catch(() => null); + + const steps = []; + try { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, unit); + steps.push({ step: `wrote ${path}`, ok: true }); + } catch (error) { + return { ok: false, reason: "write-failed", error: error.message, before, steps }; + } + + const [cmd, ...flags] = systemctl; + for (const args of [[...flags, "daemon-reload"], [...flags, "enable", "--now", 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 }; + } + + const held = await listening(port, waitMs); + steps.push({ step: `proxy holds 127.0.0.1:${port}`, ok: held }); + return { ok: held, reason: held ? null : "not-listening", before, steps, path, unit }; +} + +/** Poll rather than sleep once: a proxy that comes up in 2s should not cost 30. */ +async function defaultPortHeld(port, waitMs) { + const { connect } = await import("node:net"); + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { + const open = await new Promise((resolve) => { + const socket = connect({ host: "127.0.0.1", port }); + const done = (v) => { try { socket.destroy(); } catch { /* gone */ } resolve(v); }; + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + setTimeout(() => done(false), 1000); + }); + if (open) return true; + await new Promise((r) => setTimeout(r, 1000)); + } + return false; +} + +/** Take the proxy service away. Missing is not a failure. */ +export async function removeProxyService({ exec = run } = {}) { + const { path, systemctl } = proxyServicePaths(); + const [cmd, ...flags] = systemctl; + const steps = []; + const off = await exec(cmd, [...flags, "disable", "--now", PROXY_UNIT_NAME]); + steps.push({ step: `${cmd} ${[...flags, "disable", "--now", PROXY_UNIT_NAME].join(" ")}`, ok: off.ok, error: off.error }); + await rm(path, { force: true }); + steps.push({ step: `removed ${path}`, ok: true }); + const reload = await exec(cmd, [...flags, "daemon-reload"]); + steps.push({ step: `${cmd} ${[...flags, "daemon-reload"].join(" ")}`, ok: reload.ok, error: reload.error }); + return { ok: true, path, steps }; +} diff --git a/src/dns.mjs b/src/dns.mjs index 7f940da..df1f12d 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -2261,12 +2261,21 @@ export async function captureRestorePoint({ dropins = readDropins, read = defaultReadMaybe, now = () => new Date().toISOString(), + // Files this run creates that are not part of the resolver plan — the two + // service units, and the local root in the system trust store. Recorded the + // same way as everything else: prior content, or null for "was not here", so + // `disable` replays rather than guesses. A unit removed because it happened + // to exist is the failure this shape prevents. + extraPaths = [], } = {}) { const files = new Map(); for (const file of await dropins({ dir }).catch(() => [])) { if (!dropinNameservers(file.content).length && !dropinDomains(file.content).length) continue; files.set(`${dir}/${file.name}`, file.content); } + for (const path of extraPaths) { + if (path && !files.has(path)) files.set(path, await read(path)); + } for (const step of plan?.steps || []) { if (step.kind !== "write" && step.kind !== "remove") continue; if (!files.has(step.path)) files.set(step.path, await read(step.path)); @@ -2368,7 +2377,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { isRealTld } from "./iana-tlds.mjs"; -import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME } from "./dns-service.mjs"; +import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME, ensureProxyService, removeProxyService, proxyServicePaths, proxyWrapperPath } from "./dns-service.mjs"; import { applyPlan, daemonStatus, describePlan, detectPlatform, disablePlan, enablePlan, probeResolver, requiredPort, startDaemon, stopDaemon, @@ -2473,6 +2482,12 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { findLocalProxyImpl = findLocalProxy, autoTrustImpl = createAutoTrust, stopBridge = stopDaemon, + // The two proxy-service calls, injected for the same reason as every + // other system call here: a test must be able to exercise the branch + // without shelling out to systemctl or writing to /etc. + ensureProxy = ensureProxyService, + removeProxy = removeProxyService, + proxyWrapper = proxyWrapperPath, dropins = readDropins, manifestFile = manifestPath(), readManifest = async (path) => parseManifest(await defaultReadMaybe(path)), @@ -3181,6 +3196,30 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { const stopped = await stopBridge(); out(stopped.stopped ? " ok bridge stopped" : ` ok bridge was not running${stopped.reason ? ` (${stopped.reason})` : ""}`); + + // The proxy service `enable` installed. Taken away here, because a + // supervised proxy left holding 443 after Moshpit is turned off is a + // service the operator never asked to keep and would not think to look + // for. Safe from an escalated run: it is a system unit, so root is + // exactly the right context to stop it in. + // + // The manifest still carries whatever was at that path beforehand, so a + // machine that already had a unit of its own gets it back rather than + // losing it to a cleanup it never asked for. + // Only when there is one. A unit that was never installed needs no + // systemctl call to not exist, and reaching for /etc on a machine that + // never had a proxy is a side effect nobody asked this command for. + // Gated on the restore point, not on what happens to be in /etc. `disable` + // undoes what `enable` did; a proxy unit this tool never installed is + // somebody else's, and stopping it because it shares a filename is + // exactly the guessing the manifest exists to prevent. + const proxyWasOurs = (restore?.files || []).some((f) => f.path === proxyServicePaths().path); + if (platform === "linux" && proxyWasOurs) { + const px = await removeProxy(); + for (const step of px.steps || []) { + if (step.ok) out(` ok ${step.step}`); + } + } // Consumed. Leaving it would let a later `disable` restore a machine to a // state that is two changes old. if (restore) { @@ -3215,12 +3254,38 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { // run killed halfway leaves behind the one thing needed to undo it. The // per-file backup covers the file this run overwrites; this covers the // machine, which is a different question and the one `disable` has to ask. + // Supervised before anything goes looking for it. moshpit-proxy ships no + // unit of its own, so on every machine that installed it, it sat there + // installed, trusted and never started — which is indistinguishable from + // absent: nothing on 443, no certificate, and this command correctly + // reporting no proxy on a box that had one. + // + // Non-fatal in every direction. A machine without a working proxy resolves + // Moshpit names and cannot verify them, which is bad; a machine whose DNS + // was refused because an optional component would not start is worse. + let proxyUnitPath = null; + if (!rest.includes("--no-proxy") && platform === "linux") { + if (!proxyWrapper()) { + out(" -- no pinned-TLS proxy installed — https:// on a name will not verify"); + out(" moshcode update installs it, or:"); + out(" curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | sh"); + } else { + proxyUnitPath = proxyServicePaths().path; + const ensured = await ensureProxy({ tlds }); + for (const step of ensured.steps || []) { + out(` ${step.ok ? "ok " : "-- "} ${step.step}${step.error ? ` — ${step.error}` : ""}`); + } + if (!ensured.ok) out(` -- the proxy is not serving (${ensured.reason}) — https:// will not verify`); + } + } + const point = await captureRestorePoint({ plan, platform, backend: platform === "linux" ? linuxBackend : platform, bridge: `${DEFAULT_HOST}:${wanted}`, dropins, + extraPaths: [proxyUnitPath, servicePaths({ system: false }).path].filter(Boolean), }); const recorded2 = await applyPlan({ steps: [{ kind: "write", path: manifestFile, content: `${JSON.stringify(point, null, 2)}\n`, why: "so disable can put this machine back" }], diff --git a/test/dns-service.test.mjs b/test/dns-service.test.mjs index 4a41ed5..9489137 100644 --- a/test/dns-service.test.mjs +++ b/test/dns-service.test.mjs @@ -25,7 +25,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { installService, removeService, serviceUnit, servicePaths, UNIT_NAME } from "../src/dns-service.mjs"; -import { proxyProbeFromArgs } from "../src/dns.mjs"; +import { proxyProbeFromArgs, captureRestorePoint } from "../src/dns.mjs"; +import { proxyServiceUnit, proxyServicePaths, PROXY_UNIT_NAME } 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 }); @@ -166,3 +167,79 @@ test("without the flag it falls back to the historical synthetic name", () => { assert.equal(proxyProbeFromArgs([]).name, null); assert.equal(proxyProbeFromArgs([], []).name, null); }); + +/* ------------------------------------------------ supervising the proxy */ + +// moshpit-proxy ships no unit of its own, so on every machine that installed it +// it sat there installed, trusted, and never started. That is indistinguishable +// from absent — nothing on 443, no certificate, and `dns enable` correctly +// reporting no proxy on a box that had one. Verified against dev's +// hand-written unit, which reached the same shape independently. + +const px = (opts = {}) => proxyServiceUnit({ + wrapper: "/home/x/.local/bin/moshpit-proxy", + nodeDir: "/opt/node/bin", + home: "/home/x", + user: "x", + ...opts, +}); + +test("the proxy unit runs the wrapper with a PATH that has the right node", () => { + const text = px(); + assert.match(text, /^ExecStart=\/home\/x\/\.local\/bin\/moshpit-proxy$/m); + // The wrapper execs `node`, which systemd's PATH does not have on a mise, + // nvm or asdf box. Three separate bugs today were this exact thing. + assert.match(text, /^Environment=PATH=\/opt\/node\/bin:/m); +}); + +test("it binds 443 by capability rather than by running as root", () => { + const text = px(); + assert.match(text, /^AmbientCapabilities=CAP_NET_BIND_SERVICE$/m); + assert.match(text, /^User=x$/m, "it drops to the operator, who owns the local root"); + assert.match(text, /^Environment=MOSHPIT_PROXY_PORT=443$/m); +}); + +test("the proxy dir is the operator's, never root's", () => { + // A system unit would otherwise look in /root/.moshpit, where the local root + // that moshpit-proxy's installer generated is definitively not. + assert.match(px(), /^Environment=MOSHPIT_PROXY_DIR=\/home\/x\/\.moshpit$/m); +}); + +test("it serves the endings it is given, and says nothing when given none", () => { + assert.match(px({ tlds: ["moshpit", "eggs", "2600"] }), /^Environment=MOSHPIT_PROXY_TLDS=moshpit,eggs,2600$/m); + // Unset means the proxy's own default of `.moshpit` alone — which is why a + // healthy proxy can still fail to present a certificate for the name someone + // is actually trying to reach. + assert.doesNotMatch(px({ tlds: [] }), /MOSHPIT_PROXY_TLDS/); +}); + +test("a unit it cannot pin is refused rather than written half-formed", () => { + assert.throws(() => proxyServiceUnit({ nodeDir: "/opt/node/bin", user: "x" }), /wrapper/); + assert.throws(() => proxyServiceUnit({ wrapper: "/w", nodeDir: "/n", user: "" }), /account/); +}); + +test("the proxy is a system unit, because 443 is privileged", () => { + const { path, systemctl, scope } = proxyServicePaths(); + assert.equal(path, `/etc/systemd/system/${PROXY_UNIT_NAME}`); + assert.deepEqual(systemctl, ["systemctl"]); + assert.equal(scope, "system"); +}); + +/* ------------------------------------------------- recording them for undo */ + +test("the restore point records the units, including that they were absent", async () => { + // `null` is the load-bearing value: it is what makes `disable` remove a unit + // this run created, and what stops it removing one that was already there. + const point = await captureRestorePoint({ + plan: { steps: [] }, + platform: "linux", + bridge: "127.0.0.1:5354", + dropins: async () => [], + read: async (path) => (path === "/etc/systemd/system/moshpit-proxy.service" ? "theirs, from before\n" : null), + extraPaths: ["/etc/systemd/system/moshpit-proxy.service", "/home/x/.config/systemd/user/moshcode-dns.service"], + }); + + const byPath = Object.fromEntries(point.files.map((f) => [f.path, f.content])); + 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); +});