From 92da058774d395aec82665e9ce964aca6d6d4585 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 31 Aug 2026 00:52:40 +0000 Subject: [PATCH] fix(dns): a proxy this run started is a proxy it can trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy detection asked the proxy for a certificate under `a.`, and the proxy will not present one for a name with no key published in the registry. That refusal is deliberate — it produces a TLS failure a browser can explain rather than a reset mid-request — and `dns enable` has no way to invent a name somebody has registered, because the registry lists endings and not names. `a.` is the best it could construct, and that is precisely the shape the proxy refuses. So the check could not pass on a correctly configured machine. Three lines apart, both true as written: ok proxy holds 127.0.0.1:443 -- no pinned-TLS proxy on this machine The bridge was then started without proxy mode, so every Moshpit name answered its origin directly and a stock client got a certificate no CA had signed — on a machine that had, in the same run, installed the proxy, started it, watched it take the port, and installed the local root into the system trust store. v0.88.0 made the probe name parseable, which moved the failure from ERR_INVALID_URL to ERR_TLS_CERT_ALTNAME_INVALID and no further: a name that parses is still a name with no pin. The probe was the wrong instrument. It exists for a proxy that something else installed, where the question is genuinely open. It is not the question here: this run wrote the unit, restarted it, and confirmed the port held. That is stronger evidence of whose proxy it is than any handshake, so it is used, and the probe is kept for the case it was written for. Three tests, driving `dns enable` rather than the helper: a proxy this run started turns proxy mode on and the bridge is checked to have actually been told (`proxy: "127.0.0.1"` — without that names still answer their origin); a proxy it did not start is still probed; and no proxy at all still brings DNS up, because an optional component must never cost a machine its resolver. Suite: 2768 tests, 0 failures. Known and not fixed here: a bridge that is already running keeps the mode it started with, so on a machine where one is up this takes effect on the next restart of it rather than immediately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ThnQwoieWt8VR6N7gtgnhp --- src/dns.mjs | 25 ++++++++++++ test/dns-enable-rollback.test.mjs | 66 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/dns.mjs b/src/dns.mjs index 0a61e9ef..82bc4bf8 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -3294,6 +3294,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { // 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; + let ours = false; if (!rest.includes("--no-proxy") && platform === "linux") { if (!proxyWrapper()) { out(" -- no pinned-TLS proxy installed — https:// on a name will not verify"); @@ -3306,6 +3307,10 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { 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`); + // Started by this run, and confirmed holding the port. That is the + // strongest evidence available that the proxy on 443 is ours, and it is + // strictly better than the handshake below. + ours = ensured.ok; } } @@ -3363,6 +3368,26 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { // proxy and retracting it two lines later is worse than not looking. out(" -- the bridge already running was not started by this run, so it keeps its own"); out(" mode — to pick up proxy mode: moshcode dns disable && moshcode dns enable"); + } else if (ours) { + // No handshake needed, and none that would work. + // + // The probe asks the proxy for a certificate under some name. The proxy + // will not present one for a name with no key published in the registry — + // deliberately, so a browser gets a TLS failure it can explain rather than + // a reset mid-request — and this command has no way to invent a name that + // someone has registered. There is no endpoint that lists names, only + // endings, so `a.` is the best it could ever do and that is + // exactly the name the proxy refuses. + // + // Which made the check impossible to pass on a correctly configured + // machine: `proxy holds 127.0.0.1:443` and `no pinned-TLS proxy on this + // machine`, three lines apart, both true as written. + // + // So when this run installed the unit, started it, and watched it take the + // port, that is the answer. The probe below stays for a proxy this run did + // not start, where the question is genuinely open. + proxyAddress = { v4: DEFAULT_HOST, v6: null }; + out(` ok pinned-TLS proxy on ${DEFAULT_HOST}:${PROXY_PORT} — started by this run, so every live name will answer there`); } else if (!rest.includes("--no-proxy")) { const probeName = moshpitProbe || ""; if (!probeName) { diff --git a/test/dns-enable-rollback.test.mjs b/test/dns-enable-rollback.test.mjs index 8e16e14a..b436547d 100644 --- a/test/dns-enable-rollback.test.mjs +++ b/test/dns-enable-rollback.test.mjs @@ -472,3 +472,69 @@ function noSystem() { uid: 0, }; } + +/* --------------------------- a proxy this run started is a proxy it can trust */ + +// Detection used to ask the proxy for a certificate under `a.`. The +// proxy will not present one for a name with no key published in the registry — +// deliberately, so a browser gets a TLS failure it can explain — and nothing +// here can invent a name someone has registered, because the registry lists +// endings and not names. +// +// So on a correctly configured machine the check could not pass. Three lines +// apart, both true as written: +// +// ok proxy holds 127.0.0.1:443 +// -- no pinned-TLS proxy on this machine +// +// and the bridge was then started without proxy mode, leaving every name to +// answer its origin with a certificate no CA had signed. + +test("a proxy started by this run turns proxy mode on without a handshake", async () => { + const lines = []; + let startedWith = null; + const code = await dnsCommand(["enable"], (l) => lines.push(String(l)), { + ...noSystem(), + proxyWrapper: () => "/home/x/.local/bin/moshpit-proxy", + ensureProxy: async () => ({ ok: true, steps: [{ step: "proxy holds 127.0.0.1:443", ok: true }] }), + applyWith: async () => ({ saved: { ok: true }, applied: { ok: true, results: [] }, verified: { ok: true, checks: [] }, rolledBack: null, backups: [] }), + // If this is consulted at all the fix has not worked: the whole point is + // that no name is invented and no handshake is attempted. + findLocalProxyImpl: async () => { throw new Error("the probe must not run for a proxy we started"); }, + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + }); + + assert.equal(code, 0); + assert.match(lines.join("\n"), /started by this run/); + assert.equal(startedWith?.proxy, "127.0.0.1", "the bridge has to be told, or names still answer their origin"); +}); + +test("a proxy this run did not start is still probed", async () => { + // The question is genuinely open there, so the handshake stays. + const lines = []; + let probed = false; + await dnsCommand(["enable"], (l) => lines.push(String(l)), { + ...noSystem(), + proxyWrapper: () => "/home/x/.local/bin/moshpit-proxy", + ensureProxy: async () => ({ ok: false, reason: "not-listening", steps: [] }), + applyWith: async () => ({ saved: { ok: true }, applied: { ok: true, results: [] }, verified: { ok: true, checks: [] }, rolledBack: null, backups: [] }), + findLocalProxyImpl: async () => { probed = true; return { found: false, why: null, address: { v4: null, v6: null } }; }, + }); + + assert.equal(probed, true); +}); + +test("with no proxy installed, nothing is claimed and DNS still comes up", async () => { + const lines = []; + let startedWith = null; + const code = await dnsCommand(["enable"], (l) => lines.push(String(l)), { + ...noSystem(), + proxyWrapper: () => null, + applyWith: async () => ({ saved: { ok: true }, applied: { ok: true, results: [] }, verified: { ok: true, checks: [] }, rolledBack: null, backups: [] }), + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + }); + + assert.equal(code, 0, "a missing optional component must never refuse the machine its DNS"); + assert.equal(startedWith?.proxy, null); + assert.match(lines.join("\n"), /no pinned-TLS proxy installed/); +});