diff --git a/src/dns.mjs b/src/dns.mjs index 22374f4..7f940da 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -1664,6 +1664,37 @@ export function dnsmasqCatchAllConf({ host = DEFAULT_HOST, port = DEFAULT_PORT } * Repeatable and comma-separated both work. `address#port` matches resolv.conf * and dnsmasq rather than inventing a third spelling. */ +/** + * Which name to detect the pinned-TLS proxy with. + * + * The probe is a TLS handshake with the name in SNI, and the proxy can only + * present a certificate for a name that really exists — it fetches the + * registry pin to mint one. A synthesised `a.` is never real, so the + * handshake yields no certificate and a proxy that is installed, trusted and + * listening reports as absent. Measured against a running proxy: + * + * a.moshpit -> no certificate + * a.2600 -> no certificate + * alt.2600 -> issuer=CN=Moshpit Local CA + * + * No registry endpoint lists names, so a real one cannot be discovered here. + * `--proxy-probe ` supplies it. A bare flag is a typo rather than a + * request to probe with nothing, and is reported instead of silently falling + * back to the synthetic name that cannot work. + */ +export function proxyProbeFromArgs(args = [], claimed = []) { + const at = args.indexOf("--proxy-probe"); + if (at >= 0) { + const value = args[at + 1]; + if (value === undefined || value.startsWith("--")) return { name: null, invalid: true }; + return { name: value, invalid: false }; + } + // The historical default. Kept because it is right on a machine whose proxy + // serves every ending, and because removing it would turn "detected nothing" + // into "refused to look". + return { name: claimed[0] ? `a.${claimed[0]}` : null, invalid: false }; +} + export function upstreamsFromArgs(args = []) { const servers = []; const invalid = []; @@ -2377,6 +2408,8 @@ const USAGE = `moshcode dns — resolve Moshpit names on this machine running across reboots; --write installs and starts it, --system for a system unit rather than this user's, --remove takes it away + --proxy-probe NAME a real Moshpit name to detect + the pinned-TLS proxy with (it cannot serve a made-up one) moshcode dns filter block ads, trackers, malware and phishing at the resolver — \`moshcode dns filter help\` for the verbs @@ -2792,10 +2825,31 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { // 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. + // The probe is a TLS handshake with the name in SNI, and the proxy can only + // present a certificate for a name that actually exists — it fetches the + // registry pin to make one. A synthesised `a.` is never a real name, + // so the handshake yields no certificate and a proxy that is installed, + // trusted and listening reports as absent. Measured on a running proxy: + // + // a.moshpit -> no certificate + // a.2600 -> no certificate + // alt.2600 -> issuer=CN=Moshpit Local CA + // + // There is no registry endpoint that lists names, so the tool cannot find a + // real one by itself. `--proxy-probe ` supplies one. The issuer check + // still runs against it: naming a probe says which name to ask about, never + // that a proxy is there. let proxy = null; if (!rest.includes("--no-proxy")) { - const claimed = await fetchTlds({ registryBase }).catch(() => []); - const probeName = claimed[0] ? `a.${claimed[0]}` : null; + const explicit = proxyProbeFromArgs(rest); + if (explicit.invalid) { + out("! --proxy-probe needs a Moshpit name, e.g. --proxy-probe blue.eggs"); + out(""); + } + // The registry is only consulted when no name was given: fetching 18000 + // endings to build a probe that cannot work is pure cost. + const claimed = explicit.name ? [] : await fetchTlds({ registryBase }).catch(() => []); + const probeName = proxyProbeFromArgs(rest, claimed).name; if (probeName) { const local = await findLocalProxyImpl(probeName).catch(() => ({ found: false })); if (local.found) proxy = local.address.v4 || local.address.v6; @@ -2810,9 +2864,14 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { } 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(" https:// will fail even though the name resolves."); + out(""); + out(" If it is not installed:"); 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(""); + out(" If it IS installed and listening, the probe used a name that does not"); + out(" exist — the proxy can only serve a certificate for a real one. Name one:"); + out(" moshcode dns service --proxy-probe --write"); out(""); } diff --git a/test/dns-service.test.mjs b/test/dns-service.test.mjs index fbb88f2..4a41ed5 100644 --- a/test/dns-service.test.mjs +++ b/test/dns-service.test.mjs @@ -25,6 +25,7 @@ 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"; 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 }); @@ -128,3 +129,40 @@ 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); }); + +/* ----------------------------------------- which name detects the proxy */ + +// The probe is a TLS handshake with the name in SNI, and the proxy can only +// present a certificate for a name that exists — it fetches the registry pin to +// mint one. Measured against a proxy that was installed, trusted and listening +// on 127.0.0.1:443: +// +// a.moshpit -> no certificate +// a.2600 -> no certificate +// alt.2600 -> issuer=CN=Moshpit Local CA +// +// So the synthesised `a.` default reports "no proxy found" on a machine +// where the proxy is working perfectly, and every https:// URL then fails with +// a self-signed certificate that nothing explains. + +test("a named probe is used exactly as given", () => { + assert.deepEqual(proxyProbeFromArgs(["--proxy-probe", "alt.2600"]), { name: "alt.2600", invalid: false }); +}); + +test("a named probe does not need the registry", () => { + // The name wins over the ending list, so the 18000-ending fetch is skipped. + assert.equal(proxyProbeFromArgs(["--proxy-probe", "blue.eggs"], ["aaa", "abb"]).name, "blue.eggs"); +}); + +test("a bare --proxy-probe is a typo, not a request to probe with nothing", () => { + assert.deepEqual(proxyProbeFromArgs(["--proxy-probe"]), { name: null, invalid: true }); + assert.deepEqual(proxyProbeFromArgs(["--proxy-probe", "--write"]), { name: null, invalid: true }); +}); + +test("without the flag it falls back to the historical synthetic name", () => { + // Right on a machine whose proxy serves every ending; wrong on one that only + // serves real names — which is why the flag exists rather than replacing it. + assert.equal(proxyProbeFromArgs([], ["eggs", "hacker"]).name, "a.eggs"); + assert.equal(proxyProbeFromArgs([]).name, null); + assert.equal(proxyProbeFromArgs([], []).name, null); +});