From 936d0a86f772598b0722668e40d5faa0336b097a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 14:58:14 +0000 Subject: [PATCH] fix(dns): dns service must point names at the local proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dns enable` probes for the pinned-TLS proxy and passes it to the daemon, so Moshpit names answer on loopback and TLS terminates somewhere the local root covers. `dns service` — added in v0.78.0 — never did, so the unit it wrote started a bridge that answered every name with its origin. The origin serves a certificate no CA signed, which is the whole reason the proxy exists. So on a machine with the proxy installed, trusted and running, every https:// URL still failed, and the supervised bridge that v0.78.0 told people to use was the one way to run Moshpit where HTTPS could not work at all. The name resolved, curl said "self-signed certificate", and nothing in the output connected the two. `dns service` now runs the same probe `enable` does, at generation time — while this command can still ask — and bakes `--proxy` into ExecStart. The unit runs at boot and has no way to find out later. When no proxy is found it says so, and says what that means for https://, rather than writing a unit that resolves names and cannot serve them. `--no-proxy` skips the probe. An origin has nginx on 443 and correctly finds nothing: pointing names at loopback there would hand them to a web server that never heard of them. Four tests: the flag is written for v4 and v6, absent rather than empty when there is no proxy, and does not collide with --upstream. Suite: 2730 tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ThnQwoieWt8VR6N7gtgnhp --- src/dns-service.mjs | 8 ++++++++ src/dns.mjs | 28 +++++++++++++++++++++++++++- test/dns-service.test.mjs | 29 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/dns-service.mjs b/src/dns-service.mjs index ac299b5..224c322 100644 --- a/src/dns-service.mjs +++ b/src/dns-service.mjs @@ -67,6 +67,7 @@ export function serviceUnit({ port, registryBase = null, upstreams = [], + proxy = null, user = process.env.USER || process.env.LOGNAME, } = {}) { if (!entry) throw new Error("serviceUnit needs the entry script to run"); @@ -80,6 +81,13 @@ export function serviceUnit({ // registry. Recorded here, while a working resolver is still around to be // asked, rather than rediscovered at boot when it cannot be. if (upstreams.length) args.push("--upstream", upstreams.join(",")); + // Without this the supervised bridge answers every name with its origin, and + // the origin serves a certificate no CA signed — so `https://` fails on a + // machine where the pinned-TLS proxy is installed, trusted and running. + // `dns enable` has always probed for the proxy and passed it through; + // `dns service` did not, which made a service-managed bridge the one way to + // run Moshpit where HTTPS could never work. + if (proxy) args.push("--proxy", proxy); const exec = [execPath, ...args].map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" "); const lines = [ diff --git a/src/dns.mjs b/src/dns.mjs index 50f95b9..22374f4 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -2788,7 +2788,33 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { const upstreams = upstreamsFromArgs(rest).servers.length ? upstreamsFromArgs(rest).servers : await discoverUpstreams(); - const unit = serviceUnit({ system, entry: cliEntry(), port, registryBase, upstreams }); + // Probed the same way `enable` probes it, and for the same reason: a name + // that resolves but cannot complete a TLS handshake reads as broken to the + // person who typed the URL. Asked now, while this command can ask; the unit + // it writes runs at boot and has no way to find out later. + let proxy = null; + if (!rest.includes("--no-proxy")) { + const claimed = await fetchTlds({ registryBase }).catch(() => []); + const probeName = claimed[0] ? `a.${claimed[0]}` : null; + if (probeName) { + const local = await findLocalProxyImpl(probeName).catch(() => ({ found: false })); + if (local.found) proxy = local.address.v4 || local.address.v6; + } + } + + const unit = serviceUnit({ system, entry: cliEntry(), port, registryBase, upstreams, proxy }); + + if (proxy) { + out(`ok pinned-TLS proxy on ${proxy}:${PROXY_PORT} — names will answer there, so https:// verifies`); + out(""); + } else if (!rest.includes("--no-proxy")) { + out("! no pinned-TLS proxy found on this machine"); + out(" names will answer their origin, and a stock client cannot verify those —"); + out(" https:// will fail even though the name resolves. Install it with:"); + out(" curl -fsSL https://raw.githubusercontent.com/profullstack/moshpit-proxy/main/install.sh | sh"); + out(" then re-run this command so the unit points names at it."); + out(""); + } if (!upstreams.length) { out("! no upstream nameservers found, and none given"); diff --git a/test/dns-service.test.mjs b/test/dns-service.test.mjs index 199e1b7..fbb88f2 100644 --- a/test/dns-service.test.mjs +++ b/test/dns-service.test.mjs @@ -99,3 +99,32 @@ test("remove takes the unit away even when it was never enabled", async () => { assert.equal(result.ok, true); assert.equal(existsSync(join(home, ".config/systemd/user", UNIT_NAME)), false); }); + +/* ------------------------------------------------- pointing at the proxy */ + +// A supervised bridge that answers with the origin gives every name a +// certificate no CA signed, so `https://` fails on a machine where the +// pinned-TLS proxy is installed, trusted and running. `dns enable` has always +// probed for the proxy and passed it to the daemon; `dns service` did not, +// which made a service-managed bridge the one way to run Moshpit where HTTPS +// could not work at all. + +test("the unit points names at the local proxy when there is one", () => { + assert.match(unit({ proxy: "127.0.0.1" }), /ExecStart=.* --proxy 127\.0\.0\.1$/m); +}); + +test("a v6 proxy address survives into the unit", () => { + assert.match(unit({ proxy: "::1" }), /--proxy ::1$/m); +}); + +test("no proxy means no flag, not an empty one", () => { + // `--proxy` with nothing after it would make the daemon read the next token + // as an address, and there is no next token. + assert.doesNotMatch(unit({ proxy: null }), /--proxy/); + assert.doesNotMatch(unit(), /--proxy/); +}); + +test("upstreams and proxy coexist without eating each other's values", () => { + const text = unit({ upstreams: ["1.1.1.1", "1.0.0.1"], proxy: "127.0.0.1" }); + assert.match(text, /--upstream 1\.1\.1\.1,1\.0\.0\.1 --proxy 127\.0\.0\.1$/m); +});