From f6e497fc9840ce8e1df9fbbc5e1237910d17ddc1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 9 Aug 2026 16:50:49 +0000 Subject: [PATCH 1/2] fix(trust): refuse to install a CA:TRUE certificate as a trust anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dns trust ` installed whatever the socket served, provided the registry published a matching pin. The pin proves the registry vouches for that *key*; it says nothing about whether trusting it is bounded. A certificate installed here is installed as a trust anchor, and an anchor marked CA:TRUE may issue for any name. The code claimed the opposite — "its SAN limits it to this one name" — but a SAN describes what a certificate speaks for, not what a key trusted as an authority may sign. This is the same hole requireNameConstraints closes on the root path, arriving by the other door. It went unnoticed because openssl's `req -x509` defaults to CA:TRUE, so every origin created by setup-origin.sh serves exactly the shape that must be refused, and it is indistinguishable from a correct one until someone trusts it. The refusal names a remedy that costs nothing: re-issue as CA:FALSE reusing the key, and the published pin does not move. A gate with no way forward is a gate people route around. An unreadable certificate is treated as a CA — the safe direction to fail in. Co-Authored-By: Claude Opus 5 (1M context) --- src/trust.mjs | 67 ++++++++++++++++++++++++++++++++++++----- test/trust.test.mjs | 72 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/src/trust.mjs b/src/trust.mjs index c4a88ee..fc4c3c3 100644 --- a/src/trust.mjs +++ b/src/trust.mjs @@ -482,16 +482,53 @@ export function leafPath(name, { platform = process.platform } = {}) { : `/usr/local/share/ca-certificates/moshpit-${safe}.crt`; } +/** + * Is this certificate marked as a certificate authority? + * + * Read with node's X509 parser rather than by grepping openssl's text, because + * the answer decides whether a key gets authority over the whole clearnet and + * "CA:FALSE" is a substring of nothing but is adjacent to plenty. + * + * A certificate carrying no basicConstraints at all answers false: absent is + * not the same as asserted, and RFC 5280 §4.2.1.9 treats such a certificate as + * an end entity. + */ +export async function isCertificateAuthority(pem) { + const crypto = await import("node:crypto"); + return new crypto.X509Certificate(pem).ca === true; +} + /** * What `trust ` should do, given what the socket served and what the * registry says about it. * * Pure, so the refusal path is testable without a network or a trust store. */ -export function leafTrustPlan({ name, pin, published, platform = process.platform } = {}) { +export function leafTrustPlan({ name, pin, published, platform = process.platform, ca = false } = {}) { const accepted = pinAccepted(pin, published); if (!accepted.ok) return { ok: false, refused: true, why: accepted.why }; + // A certificate installed here is installed as a *trust anchor*, and an + // anchor marked CA:TRUE may issue for any name in the world. The SAN says + // what the certificate speaks for; it says nothing about what a key trusted + // as an authority may go on to sign — so `subjectAltName=DNS:seo.rank` on a + // CA:TRUE certificate is not the bound it looks like, and trusting one would + // hand its holder google.com along with their own name. + // + // This is the same hole `requireNameConstraints` exists to close on the root + // path, arriving by the other door. It went unnoticed because openssl's + // `req -x509` defaults to CA:TRUE, so every origin set up before that default + // was overridden serves exactly the shape that must be refused — and it looks + // identical to a correct one until someone trusts it. + if (ca) { + return { + ok: false, + refused: true, + kind: "ca", + why: `${name} serves a certificate marked CA:TRUE — trusted directly, its key could vouch for any name`, + }; + } + const file = leafPath(name, { platform }); if (!file) return { ok: false, why: `${name} is not a name that can be written to a file` }; @@ -499,10 +536,11 @@ export function leafTrustPlan({ name, pin, published, platform = process.platfor ok: true, why: accepted.why, file, - // A self-signed leaf is its own trust anchor, and its SAN limits it to this - // one name — so trusting it vouches for `seo.rank` and nothing else. That - // is a far smaller grant than a CA, which is why this path needs no - // name constraints argument to be defensible. + // With CA:FALSE established above, a self-signed leaf is its own trust + // anchor and its SAN limits it to this one name — so trusting it vouches + // for `seo.rank` and nothing else. That is a far smaller grant than a CA, + // which is why this path needs no name-constraints argument to be + // defensible. It is only true because of the check above. refresh: platform === "darwin" ? { command: "security", args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file] } : { command: "update-ca-certificates", args: [] }, @@ -571,10 +609,25 @@ export async function trustName(name, out, deps = {}) { return 1; } - const plan = leafTrustPlan({ name, pin, published, platform }); + // Read off the certificate rather than assumed: an origin set up before + // `setup-origin.sh` overrode openssl's default serves CA:TRUE, and that is + // the one shape this must not install. + const ca = await isCertificateAuthority(served.stdout).catch(() => true); + + const plan = leafTrustPlan({ name, pin, published, platform, ca }); if (!plan.ok) { out(`REFUSED — ${plan.why}`); - if (plan.refused) { + if (plan.kind === "ca") { + // A refusal with no way forward is a refusal people route around, and + // this one has a cheap way forward that costs nothing anywhere else: the + // pin is over the key, so re-issuing the certificate from the same key + // leaves the published pin untouched. Nothing has to be republished and + // no client holding the old pin breaks. + out(" its SAN says what it speaks for, not what it may sign — an anchor"); + out(" marked CA:TRUE is not limited to the name printed on it."); + out(" re-issue it as CA:FALSE; the key is reused, so the pin does not move:"); + out(` sudo sh scripts/setup-origin.sh ${name} # from moshpit-proxy`); + } else if (plan.refused) { out(` served ${pin}`); out(published.length ? ` pinned ${published.join("\n ")}` : " pinned (none)"); out(" moshcode will not trust a certificate the registry does not vouch for."); diff --git a/test/trust.test.mjs b/test/trust.test.mjs index 3a2facc..c9feab9 100644 --- a/test/trust.test.mjs +++ b/test/trust.test.mjs @@ -436,8 +436,15 @@ test("the refusal a session prints carries its remedy", async () => { import { fetchCertificateCommand, leafPath, leafTrustPlan, pinAccepted, pinFromCertificate, trustName } from "../src/trust.mjs"; -/** A real self-signed leaf, the shape a Moshpit origin serves. */ -function leaf(cn = "seo.rank") { +/** + * A real self-signed leaf, the shape a Moshpit origin serves. + * + * `ca` is a parameter because both shapes are real. CA:FALSE is what + * `setup-origin.sh` issues and the only shape that may be trusted directly; + * CA:TRUE is what openssl's `req -x509` produces by default, which is what + * every origin created before that default was overridden is still serving. + */ +function leaf(cn = "seo.rank", { ca = false } = {}) { try { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "leaf-")); const key = path.join(dir, "k.pem"); @@ -445,6 +452,7 @@ function leaf(cn = "seo.rank") { execFileSync("openssl", [ "req", "-x509", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-keyout", key, "-out", crt, "-days", "1", "-subj", `/CN=${cn}`, "-addext", `subjectAltName=DNS:${cn}`, + "-addext", `basicConstraints=critical,CA:${ca ? "TRUE" : "FALSE"}`, ], { stdio: "ignore" }); const pem = fs.readFileSync(crt, "utf8"); fs.rmSync(dir, { recursive: true, force: true }); @@ -564,6 +572,66 @@ test("without root it says so instead of failing obscurely on the write", async assert.match(lines.join("\n"), /needs root/); }); +test("a certificate marked CA:TRUE is refused however good its pin is", async () => { + // The dangerous shape, and the one that looked safe: a matching pin proves + // the registry vouches for the *key*, not that trusting it is bounded. An + // anchor marked CA:TRUE may issue for any name, so installing one would hand + // its holder the clearnet — the SAN on it constrains nothing it signs. + const pem = leaf("seo.rank", { ca: true }); + if (!pem) return; + const pin = await pinFromCertificate(pem); + const lines = []; + const ran = []; + const code = await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => { + ran.push(c); + return a.join(" ").includes("s_client") ? { ok: true, stdout: pem, stderr: "" } : { ok: true, stdout: "", stderr: "" }; + }, + fetchImpl: async () => ({ ok: true, json: async () => ({ pins: [pin] }) }), + writeFile: async () => assert.fail("a CA must never be installed as an anchor, pin or no pin"), + uid: 0, platform: "linux", + }); + assert.equal(code, 1); + assert.match(lines.join("\n"), /REFUSED/); + assert.match(lines.join("\n"), /CA:TRUE/); + assert.ok(!ran.includes("update-ca-certificates"), "and the store is never refreshed"); +}); + +test("the CA refusal says how to get past it, since the fix costs no pin change", async () => { + // A gate with no way forward is a gate people route around. Re-issuing from + // the same key leaves the published pin untouched, so the remedy is free — + // and saying so is the difference between "fix it" and "give up". + const pem = leaf("seo.rank", { ca: true }); + if (!pem) return; + const pin = await pinFromCertificate(pem); + const lines = []; + await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => (a.join(" ").includes("s_client") ? { ok: true, stdout: pem, stderr: "" } : { ok: true, stdout: "", stderr: "" }), + fetchImpl: async () => ({ ok: true, json: async () => ({ pins: [pin] }) }), + writeFile: async () => {}, + uid: 0, platform: "linux", + }); + const said = lines.join("\n"); + assert.match(said, /CA:FALSE/); + assert.match(said, /setup-origin\.sh seo\.rank/); + assert.match(said, /pin does not move/); +}); + +test("an unreadable certificate is treated as a CA, not waved through", async () => { + // The safe direction to fail in. If the parse throws, whether this is an + // authority is unknown — and "unknown" must not install an anchor. + const lines = []; + const code = await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => (a.join(" ").includes("s_client") + ? { ok: true, stdout: "-----BEGIN CERTIFICATE-----\nnot a certificate\n-----END CERTIFICATE-----\n", stderr: "" } + : { ok: true, stdout: "", stderr: "" }), + fetchImpl: async () => ({ ok: true, json: async () => ({ pins: ["whatever="] }) }), + writeFile: async () => assert.fail("nothing may be written when the certificate cannot be read"), + uid: 0, platform: "linux", + }); + assert.equal(code, 1); +}); + test("no name asks for one rather than guessing", async () => { const lines = []; assert.equal(await trustName("", (l) => lines.push(l), {}), 1); From ebcae523c885bf17c4612d1e8a577540cb5b7736 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 9 Aug 2026 17:20:41 +0000 Subject: [PATCH 2/2] feat(dns): turn proxy mode on in `dns enable`, so a stock client just works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `addressAnswer` could already point every live name at the local pinned-TLS proxy, `dns start --proxy` could already switch it on, and `dns enable` already installed the root the proxy signs with. Nothing ever connected them. So names resolved straight to their origin, a stock client got a certificate no CA had signed, and the trust store was populated for a proxy that was never on the path — which reads, correctly, as "this is still broken". `dns enable` now probes for the proxy and starts the bridge in proxy mode when it finds one. The probe is a TLS handshake, not a connect. `proxyReachable` answers "is something listening", and on one common class of machine the two answers differ in the worst way: an origin runs nginx on 0.0.0.0:443, which covers loopback, so a connect succeeds and proxy mode would point every live Moshpit name on the machine at a web server that has never heard of them. That is not a certificate problem, it is every name serving the wrong site at once. So `proxyServes` completes a handshake and checks who issued the certificate. The proxy mints a leaf per name from the root it generated here; nginx serves the origin's own self-signed certificate, issued by itself. Nothing is trusted in the process — the peer certificate is read, not verified, and only the issuer name is taken from it. Refusing is the default in every uncertain case. Proxy mode with nothing behind it resolves every name and then refuses every connection, which looks like the sites are down while `dig` stays healthy. A bridge this run did not start keeps its own mode, so the probe is skipped rather than run and then discarded — announcing a proxy and retracting it two lines later is worse than not looking. `--no-proxy` opts out. Co-Authored-By: Claude Opus 5 (1M context) --- src/cli-schema.mjs | 1 + src/dns-system.mjs | 6 +- src/dns.mjs | 168 ++++++++++++++++++++++- test/dns-enable-rollback.test.mjs | 4 + test/dns-proxy-mode.test.mjs | 215 +++++++++++++++++++++++++++++- 5 files changed, 391 insertions(+), 3 deletions(-) diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 344439a..fe62156 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -197,6 +197,7 @@ export const CORE_CLI_COMMANDS = [ ["--port ", "port for the bridge", "5354"], ["--registry ", "registry to resolve against", "https://pit.moshcode.sh"], ["--no-trust", "with enable: route names but skip the local CA", ""], + ["--no-proxy", "with enable: answer origins rather than the local proxy", ""], ], examples: [ ["sudo moshcode dns enable", "route Moshpit endings here"], diff --git a/src/dns-system.mjs b/src/dns-system.mjs index 6ba060a..6e43984 100644 --- a/src/dns-system.mjs +++ b/src/dns-system.mjs @@ -373,13 +373,17 @@ export async function daemonStatus(path = pidfilePath()) { * not survive a reboot. `moshcode dns status` says so plainly rather than * letting someone discover it when their names stop resolving. */ -export async function startDaemon({ port, registryBase, path = pidfilePath(), entry }) { +export async function startDaemon({ port, registryBase, path = pidfilePath(), entry, proxy = null }) { const existing = await daemonStatus(path); if (existing.running) return { started: false, pid: existing.pid, alreadyRunning: true }; await mkdir(dirname(path), { recursive: true }); const args = [entry, "dns", "start", "--port", String(port)]; if (registryBase) args.push("--registry", registryBase); + // Passed at spawn time because it is what the resolver answers with, not + // something it can be told later — there is no channel to a detached daemon + // short of restarting it, which is why `enable` decides this before starting. + if (proxy) args.push("--proxy", proxy); const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" }); child.unref(); diff --git a/src/dns.mjs b/src/dns.mjs index 78de619..4bccea9 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -695,6 +695,111 @@ export function proxyReachable(address, port = 443, { connect = null, timeoutMs }); } +/** The root moshpit-proxy signs with. Its leaves are how the proxy is recognised. */ +export const PROXY_ROOT_CN = "Moshpit Local CA"; + +/** + * Is the thing on that address *our proxy*, or merely something on port 443? + * + * `proxyReachable` answers the second question, and on one common class of + * machine the two answers differ in the worst possible way. An origin runs + * nginx on `0.0.0.0:443`, which covers loopback — so a bare connect succeeds, + * proxy mode is turned on, and every live Moshpit name on the machine is + * pointed at a web server that knows nothing about them. That is not a + * certificate problem, it is every name on the machine serving the wrong site + * at once, and the connect probe cannot see it coming. + * + * So this asks the question that actually distinguishes them: complete a TLS + * handshake and look at who issued the certificate. The proxy mints a leaf per + * name from the root it generated on this machine, so the issuer is that root. + * Anything else — nginx with the origin's own self-signed certificate, some + * unrelated service — is issued by something else and is refused. + * + * `rejectUnauthorized` is off deliberately, and it is not a hole: nothing is + * sent, the peer certificate is read rather than trusted, and the only thing + * accepted from it is the issuer name. Verifying properly would require the + * root to already be installed, which is a step that has not happened yet at + * the point this runs. + */ +export async function proxyServes(address, name, { + port = PROXY_PORT, + timeoutMs = 2500, + tlsConnect = null, +} = {}) { + const connectImpl = tlsConnect || (await import("node:tls")).connect; + return new Promise((resolve) => { + let socket; + const done = (result) => { + try { socket?.destroy(); } catch { /* already gone */ } + resolve(result); + }; + try { + socket = connectImpl({ + host: address, + port, + servername: name, + rejectUnauthorized: false, + // The proxy forces http/1.1; offering nothing keeps this a pure + // handshake rather than a protocol negotiation that could be declined. + ALPNProtocols: ["http/1.1"], + }); + // Not unref'd, for the reason proxyReachable spells out: this timer is the + // only guarantee the promise settles. + const timer = setTimeout(() => done({ ok: false, why: "timed out" }), timeoutMs); + socket.once("secureConnect", () => { + clearTimeout(timer); + const cert = socket.getPeerCertificate?.() || {}; + const issuer = cert.issuer?.CN || ""; + if (issuer === PROXY_ROOT_CN) return done({ ok: true, issuer }); + done({ + ok: false, + issuer, + // Named as what it means rather than what was seen: "issuer is + // chovy.hacker" is a fact, "something else owns 443" is the reason + // proxy mode must stay off. + why: issuer + ? `something other than the proxy owns ${address}:${port} — it served a certificate issued by ${JSON.stringify(issuer)}` + : `something other than the proxy owns ${address}:${port}`, + }); + }); + socket.once("error", (err) => { + clearTimeout(timer); + done({ ok: false, why: err?.code || err?.message || "connection failed" }); + }); + } catch (err) { + resolve({ ok: false, why: err?.message || "connection failed" }); + } + }); +} + +/** + * Which loopback addresses have the proxy behind them, if any. + * + * Both families are asked because answering one of them wrongly is an outage: + * a v6-only answer for a v4-only listener is a refused connection that reads as + * the site being down. `addressAnswer` handles the asymmetry; this just reports + * what is actually there. + */ +export async function findLocalProxy(name, { candidates = ["127.0.0.1", "::1"], ...options } = {}) { + const reachable = []; + let why = null; + for (const address of candidates) { + const result = await proxyServes(address, name, options); + if (result.ok) reachable.push(address); + // Keep the most informative refusal: "something else owns 443" is worth + // saying out loud, where "ECONNREFUSED" just means no proxy is installed. + else if (result.issuer && !why) why = result.why; + } + return { + found: reachable.length > 0, + why, + address: { + v4: reachable.find((a) => isIP(a) === 4) || null, + v6: reachable.find((a) => isIP(a) === 6) || null, + }, + }; +} + export async function addressAnswer(name, options = {}) { const { parkingAddress, wantsV6 = false, proxyAddress = null } = options; const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra }); @@ -2088,6 +2193,10 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine --no-trust with enable: route names but skip the local CA. They will resolve and then fail TLS, which is the state this flag exists to leave you in deliberately. + --no-proxy with enable: answer each name's origin rather than the local + pinned-TLS proxy. Only the proxy can hand a stock client a + certificate it will accept, so this is the other half of the + same deliberate breakage. The registry speaks HTTP, not DNS, so nothing outside a browser can reach a Moshpit name until this bridge is running and your resolver points at it. @@ -2123,6 +2232,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { bridgeStatus = daemonStatus, startBridge = startDaemon, proxyReachableImpl = proxyReachable, + findLocalProxyImpl = findLocalProxy, autoTrustImpl = createAutoTrust, stopBridge = stopDaemon, dropins = readDropins, @@ -2723,9 +2833,65 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { // serve is silently shadowed by the bridge it said would not be started. // Honoring the note is the whole of the fix. const reusing = cleared.holder && cleared.holderForwards ? cleared.holder : null; + + // Proxy mode, decided here because the bridge cannot be told later: what a + // resolver answers with is fixed when it starts. + // + // This is the step that was missing, and its absence is why the whole + // feature read as broken. Everything else was built — the proxy verifies + // origins against registry pins and re-signs with a root `dns enable` + // installs, and `addressAnswer` knows how to point names at it — but + // nothing ever turned it on, so names resolved straight to their origin and + // a stock client got a certificate no CA had signed. Trust was installed + // for a proxy that was never on the path. + // + // Refusing is the safe direction and the default: with proxy mode on and + // nothing behind it, every Moshpit name on the machine resolves and then + // refuses the connection. + let proxyAddress = null; + if (reusing) { + // A bridge this run did not start keeps whatever mode it was started + // with: `startDaemon` decides "already running" from our pidfile, and + // there is no channel to a detached daemon to change its mind. So the + // probe is skipped rather than run and then discarded — announcing a + // 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 (!rest.includes("--no-proxy")) { + const probeName = moshpitProbe || ""; + if (!probeName) { + out(" -- proxy mode not checked — no Moshpit name to probe with"); + } else { + const local = await findLocalProxyImpl(probeName); + if (local.found) { + proxyAddress = local.address; + const at = [local.address.v4, local.address.v6].filter(Boolean).join(", "); + out(` ok pinned-TLS proxy on ${at}:${PROXY_PORT} — every live name will answer there`); + } else if (local.why) { + // The origin case, and the one worth naming precisely. A machine that + // serves Moshpit names has nginx on 443, so the proxy cannot be on the + // path here and pointing names at loopback would hand all of them to + // a web server that has never heard of them. + out(` -- ${local.why}`); + out(" proxy mode stays off — names will answer their origin."); + } else { + out(" -- no pinned-TLS proxy on this machine — names will answer their origin"); + out(" a stock client cannot verify those: https://github.com/profullstack/moshpit-proxy"); + } + } + } + const started = reusing ? { started: false, pid: reusing.pid, alreadyRunning: true, reused: true } - : await startBridge({ port: wanted, registryBase, entry: cliEntry() }); + : await startBridge({ + port: wanted, + registryBase, + entry: cliEntry(), + // v4 by preference: `dns start --proxy` takes one address and probes + // both families itself, so handing it the v4 loopback lets it find ::1 + // too rather than pinning the answer to one family. + proxy: proxyAddress ? (proxyAddress.v4 || proxyAddress.v6) : null, + }); out(started.reused ? ` ok using the bridge already on ${DEFAULT_HOST}:${wanted} (pid ${reusing.pid || "?"}) — not starting a second one` : started.alreadyRunning diff --git a/test/dns-enable-rollback.test.mjs b/test/dns-enable-rollback.test.mjs index b8614c7..996c812 100644 --- a/test/dns-enable-rollback.test.mjs +++ b/test/dns-enable-rollback.test.mjs @@ -454,6 +454,10 @@ function noSystem() { preflight: async () => ({ ok: true, blockers: [], conflicts: [], holder: null }), verify: async () => ({ ok: true, checks: [] }), bridgeStatus: async () => ({ running: false, pid: null, stale: false }), + // No proxy, which is the state these tests were written in. Stubbed rather + // than left to the real probe, which would open a TLS connection to + // whatever holds 443 on the machine running the suite. + findLocalProxyImpl: async () => ({ found: false, why: null, address: { v4: null, v6: null } }), startBridge: async () => ({ started: true, pid: 1, alreadyRunning: false }), stopBridge: async () => ({ stopped: true, reason: null }), dropins: async () => [], diff --git a/test/dns-proxy-mode.test.mjs b/test/dns-proxy-mode.test.mjs index 9e07553..2c8323a 100644 --- a/test/dns-proxy-mode.test.mjs +++ b/test/dns-proxy-mode.test.mjs @@ -17,8 +17,15 @@ import test from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import dgram from "node:dgram"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import tls from "node:tls"; +import { execFileSync } from "node:child_process"; -import { addressAnswer, dnsCommand, proxyReachable, PROXY_PORT } from "../src/dns.mjs"; +import { + PROXY_PORT, PROXY_ROOT_CN, addressAnswer, dnsCommand, findLocalProxy, proxyReachable, proxyServes, +} from "../src/dns.mjs"; /** Hold a UDP port so the bind fails and `start` returns instead of serving. */ function holdUdp() { @@ -214,3 +221,209 @@ test("--proxy with a host name refuses instead of NODATA'ing every live name", a await held.release(); } }); + +/* ------------------------------------------------------------ certificates -*/ + +/** + * A leaf issued by a root named `cn`, the way moshpit-proxy issues one per name. + * + * Real openssl rather than a fixture: the whole decision reads + * `getPeerCertificate().issuer.CN` off a completed handshake, and a hand-built + * object would prove only that the test sets the field the code looks at. + */ +function chain(leafName, issuerCn) { + const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "proxy-mode-")); + const p = (f) => path.join(dir, f); + const quiet = { stdio: "ignore" }; + + execFileSync("openssl", [ + "req", "-x509", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-keyout", p("ca.key"), "-out", p("ca.crt"), "-days", "1", + "-subj", `/CN=${issuerCn}`, "-addext", "basicConstraints=critical,CA:TRUE", + ], quiet); + + execFileSync("openssl", [ + "req", "-new", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-keyout", p("leaf.key"), "-out", p("leaf.csr"), "-subj", `/CN=${leafName}`, + ], quiet); + + fsSync.writeFileSync(p("ext.cnf"), `subjectAltName=DNS:${leafName}\nbasicConstraints=critical,CA:FALSE\n`); + execFileSync("openssl", [ + "x509", "-req", "-in", p("leaf.csr"), "-CA", p("ca.crt"), "-CAkey", p("ca.key"), + "-CAcreateserial", "-out", p("leaf.crt"), "-days", "1", "-extfile", p("ext.cnf"), + ], quiet); + + return { cert: fsSync.readFileSync(p("leaf.crt")), key: fsSync.readFileSync(p("leaf.key")), dir }; +} + +/** A self-signed certificate: an origin's own, which is what nginx serves. */ +function selfSigned(name) { + const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "origin-")); + const p = (f) => path.join(dir, f); + execFileSync("openssl", [ + "req", "-x509", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-keyout", p("k.pem"), "-out", p("c.pem"), "-days", "1", + "-subj", `/CN=${name}`, "-addext", `subjectAltName=DNS:${name}`, + "-addext", "basicConstraints=critical,CA:FALSE", + ], { stdio: "ignore" }); + return { cert: fsSync.readFileSync(p("c.pem")), key: fsSync.readFileSync(p("k.pem")) }; +} + +/** A TLS server on an ephemeral loopback port, closed when the test ends. */ +async function serve(t, { cert, key }) { + const server = tls.createServer({ cert, key }, (socket) => socket.end()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => new Promise((resolve) => server.close(resolve))); + return server.address().port; +} + +/* ------------------------------------------------------------------ probe --*/ + +test("the proxy is recognised by who issued the certificate it serves", async (t) => { + const port = await serve(t, chain("chovy.hacker", PROXY_ROOT_CN)); + const result = await proxyServes("127.0.0.1", "chovy.hacker", { port }); + assert.equal(result.ok, true); + assert.equal(result.issuer, PROXY_ROOT_CN); +}); + +test("nginx on an origin is refused, however reachable it is", async (t) => { + // The case that makes a bare connect probe unsafe. This server answers, and + // answering is exactly what would have turned proxy mode on and pointed every + // name on the machine at a web server that knows nothing about them. + const port = await serve(t, selfSigned("chovy.hacker")); + const result = await proxyServes("127.0.0.1", "chovy.hacker", { port }); + assert.equal(result.ok, false); + assert.match(result.why, /something other than the proxy owns/); + assert.equal(result.issuer, "chovy.hacker", "the origin's certificate is its own issuer"); +}); + +test("nothing listening is a refusal, not a crash", async () => { + // Port 1 on loopback: reserved, never bound. + const result = await proxyServes("127.0.0.1", "chovy.hacker", { port: 1, timeoutMs: 1500 }); + assert.equal(result.ok, false); + assert.ok(result.why); + assert.equal(result.issuer, undefined, "a connection that never completed has no issuer to report"); +}); + +test("findLocalProxy reports the family it actually found, not both", async (t) => { + const port = await serve(t, chain("chovy.hacker", PROXY_ROOT_CN)); + const found = await findLocalProxy("chovy.hacker", { candidates: ["127.0.0.1"], port }); + assert.equal(found.found, true); + assert.equal(found.address.v4, "127.0.0.1"); + // Answering ::1 for a v4-only listener is a refused connection that reads as + // the site being down. + assert.equal(found.address.v6, null); +}); + +test("findLocalProxy keeps the informative refusal, not the boring one", async (t) => { + const port = await serve(t, selfSigned("chovy.hacker")); + const found = await findLocalProxy("chovy.hacker", { candidates: ["127.0.0.1"], port }); + assert.equal(found.found, false); + // "something else owns 443" is worth printing; ECONNREFUSED just means no + // proxy is installed and has its own, softer message. + assert.match(found.why, /something other than the proxy owns/); +}); + +/* ------------------------------------------------------------ dns enable ---*/ + +function enableDeps(extra = {}) { + return { + tlds: async () => ["hacker"], + safety: async () => ({ safe: true, upstreams: ["1.1.1.1"], why: "no bridge is running yet" }), + preflight: async () => ({ ok: true, blockers: [], conflicts: [], holder: null }), + verify: async () => ({ ok: true, checks: [] }), + bridgeStatus: async () => ({ running: false, pid: null, stale: false }), + stopBridge: async () => ({ stopped: true, reason: null }), + dropins: async () => [], + readManifest: async () => null, + manifestFile: path.join(os.tmpdir(), "moshcode-proxy-mode-manifest.json"), + uid: 0, + ...extra, + }; +} + +test("a proxy that is really there is switched on, without being asked", async () => { + // The whole point. Everything else was already built — the proxy checks + // origins against registry pins, `dns enable` installs the root it signs with + // — and none of it was on the path, because nothing ever turned this on. + const lines = []; + let startedWith = null; + await dnsCommand(["enable"], (l) => lines.push(String(l)), enableDeps({ + findLocalProxyImpl: async () => ({ found: true, why: null, address: { v4: "127.0.0.1", v6: "::1" } }), + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + assert.equal(startedWith.proxy, "127.0.0.1"); + assert.match(lines.join("\n"), /pinned-TLS proxy on 127\.0\.0\.1, ::1:443/); +}); + +test("no proxy means names answer their origin, and it says why that is not enough", async () => { + const lines = []; + let startedWith = null; + await dnsCommand(["enable"], (l) => lines.push(String(l)), enableDeps({ + findLocalProxyImpl: async () => ({ found: false, why: null, address: { v4: null, v6: null } }), + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + // Refusing is the safe direction: proxy mode with nothing behind it resolves + // every Moshpit name and then refuses every connection. + assert.equal(startedWith.proxy, null); + assert.match(lines.join("\n"), /no pinned-TLS proxy on this machine/); +}); + +test("something else on 443 is named, rather than being quietly treated as the proxy", async () => { + const lines = []; + let startedWith = null; + await dnsCommand(["enable"], (l) => lines.push(String(l)), enableDeps({ + findLocalProxyImpl: async () => ({ + found: false, + why: 'something other than the proxy owns 127.0.0.1:443 — it served a certificate issued by "chovy.hacker"', + address: { v4: null, v6: null }, + }), + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + assert.equal(startedWith.proxy, null); + const said = lines.join("\n"); + assert.match(said, /something other than the proxy owns/); + assert.match(said, /proxy mode stays off/); +}); + +test("--no-proxy skips the probe entirely", async () => { + const lines = []; + let probed = false; + let startedWith = null; + await dnsCommand(["enable", "--no-proxy"], (l) => lines.push(String(l)), enableDeps({ + findLocalProxyImpl: async () => { probed = true; return { found: true, why: null, address: { v4: "127.0.0.1", v6: null } }; }, + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + assert.equal(probed, false, "the flag means do not look, not look and ignore"); + assert.equal(startedWith.proxy, null); +}); + +test("a bridge this run did not start is not claimed to be proxying", async () => { + // `startDaemon` decides "already running" from our pidfile, so a bridge + // started by systemd or by hand keeps whatever mode it has. Printing + // "proxying every live name" over it is the exact species of lie this whole + // change exists to stop telling. + const lines = []; + let startedWith = "untouched"; + let probed = false; + await dnsCommand(["enable"], (l) => lines.push(String(l)), enableDeps({ + preflight: async () => ({ + ok: true, blockers: [], conflicts: [], + holder: { pid: 4242, command: "bun" }, holderForwards: true, + }), + findLocalProxyImpl: async () => { probed = true; return { found: true, why: null, address: { v4: "127.0.0.1", v6: null } }; }, + startBridge: async (opts) => { startedWith = opts; return { started: true, pid: 1, alreadyRunning: false }; }, + })); + + const said = lines.join("\n"); + assert.equal(startedWith, "untouched", "the running bridge is reused, not restarted"); + assert.equal(probed, false, "and not probed, since the answer could not be acted on"); + assert.match(said, /keeps its own/); + // Announcing a proxy and retracting it two lines later is worse than not + // looking: the reader has already believed the first line. + assert.doesNotMatch(said, /every live name will answer there/); +});