diff --git a/src/dns.mjs b/src/dns.mjs index b0c7f8f..7eff9aa 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -38,6 +38,7 @@ export const TYPE_A = 1; export const TYPE_AAAA = 28; const CLASS_IN = 1; const RCODE_OK = 0; +const RCODE_SERVFAIL = 2; const RCODE_NXDOMAIN = 3; /* ---------------------------------------------------------------- wire codec */ @@ -358,18 +359,107 @@ export function targetAddress(target) { * `parkingAddress` is resolved once by the caller (an A record must carry an * IP, not a name) and passed in, so the server itself never does clearnet DNS. */ +/** + * Send a query to an upstream nameserver and hand back its answer verbatim. + * + * Deliberately a byte proxy rather than a parse-and-rebuild. We forward + * question types this bridge has no opinion about — SVCB, SRV, DNSKEY, + * whatever arrives — and re-encoding them would mean implementing the whole + * record space correctly to avoid corrupting answers we only need to relay. + */ +export function forwardQuery(msg, upstream, { timeoutMs = 3000 } = {}) { + const [address, portText] = String(upstream).split("#"); + const port = Number(portText) || 53; + return new Promise((resolve) => { + const socket = dgram.createSocket(isIP(address) === 6 ? "udp6" : "udp4"); + let settled = false; + const finish = (reply) => { + if (settled) return; + settled = true; + try { socket.close(); } catch { /* already closing */ } + resolve(reply); + }; + const timer = setTimeout(() => finish(null), timeoutMs); + timer.unref?.(); + socket.on("message", (reply) => { clearTimeout(timer); finish(reply); }); + socket.on("error", () => { clearTimeout(timer); finish(null); }); + try { + socket.send(msg, port, address); + } catch { + clearTimeout(timer); + finish(null); + } + }); +} + +/** + * Is this a name we are authoritative for? + * + * The gate that makes catch-all routing safe. With `Domains=~.` every lookup + * on the machine arrives here, and `google.com` is two labels exactly like + * `blue.eggs` is — so parsing alone would have us answer for the clearnet. + * Only an ending someone has actually claimed is ours; everything else is + * forwarded untouched. + * + * An unknown ending set means "not ours" rather than "ours". Failing that way + * round costs a Moshpit name that does not resolve until the registry answers + * again; the other way round costs the whole internet on that machine. + */ +export function isOurs(name, tldSet) { + if (!(tldSet instanceof Set) || tldSet.size === 0) return false; + const parsed = parseRegistryName(name); + return Boolean(parsed) && tldSet.has(parsed.tld); +} + export function createServer(options = {}) { const { port = DEFAULT_PORT, host = DEFAULT_HOST, ttl = DEFAULT_TTL, onQuery = () => {}, + // Empty by default, which keeps the old behaviour exactly: with no + // upstreams there is nothing to forward to, so the bridge stays the + // narrow per-ending resolver it has always been and answers only for + // names it is authoritative for. + upstreams = [], + tldSet = null, + forwardTimeoutMs = 3000, } = options; const socket = dgram.createSocket("udp4"); socket.on("message", async (msg, rinfo) => { const query = parseQuery(msg); if (!query) return; // malformed, or a response — say nothing at all + + // Catch-all routing puts every lookup on the machine through here. Anything + // that is not an ending someone has claimed belongs to the ordinary + // internet and is relayed byte for byte, including question types this + // bridge has no opinion about. + if (upstreams.length && !isOurs(query.name, tldSet)) { + let relayed = null; + for (const upstream of upstreams) { + relayed = await forwardQuery(msg, upstream, { timeoutMs: forwardTimeoutMs }); + if (relayed) break; + } + onQuery({ name: query.name, type: query.type, address: null, forwarded: true }); + try { + // SERVFAIL, not NXDOMAIN, when every upstream is silent: "I could not + // find out" is retried elsewhere, "it does not exist" gets cached and + // the name stays broken after the network comes back. + socket.send( + relayed || Buffer.concat([ + header(query.id, { rcode: RCODE_SERVFAIL, answers: 0, recursionDesired: query.recursionDesired }), + msg.subarray(12, query.questionEnd), + ]), + rinfo.port, + rinfo.address, + ); + } catch { + /* client vanished */ + } + return; + } + let address = null; let exists = false; // Only address questions can be answered with an address; everything else @@ -410,6 +500,74 @@ export function createServer(options = {}) { * `~tld` is a routing-only domain: it sends queries for that suffix here * without making this resolver the default for anything else on the machine. */ +/* ------------------------------------------------- catch-all routing */ + +/** + * Route every lookup here, instead of naming each claimed ending. + * + * The per-ending form does not scale and fails silently when it stops. Listing + * 4586 endings on one `Domains=` line made systemd-resolved take them + * alphabetically until it hit its own cap, reject the remaining 3496 with + * "Argument list too long" one line at a time in the journal, and report + * success. Names past the cut were configured on disk and absent from the + * resolver, so `moshcode dns resolve` answered and `curl` did not — with + * nothing in between to say why. Every new ending anyone claims makes that + * worse. + * + * `~.` is one entry that never grows. The cost is that this bridge now sees + * every lookup on the machine, so it has to be a resolver rather than an + * oracle: anything that is not a claimed Moshpit name is forwarded upstream + * untouched, and any failure forwards too. Breaking DNS for the whole box is a + * far worse outcome than failing to resolve a Moshpit name. + */ +export function resolvedCatchAllConf({ host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) { + return [ + "# Written by `moshcode dns install`. Sends every lookup to the local", + "# bridge, which answers Moshpit endings and forwards the rest upstream.", + "#", + "# Routing each ending by name instead does not survive the registry", + "# growing: systemd-resolved caps how many search domains it accepts and", + "# drops the rest with no error a caller can see.", + "[Resolve]", + `DNS=${host}:${port}`, + "Domains=~.", + "", + ].join("\n"); +} + +/** The dnsmasq equivalent: one upstream for everything. */ +export function dnsmasqCatchAllConf({ host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) { + return [ + "# Written by `moshcode dns install`.", + "# no-resolv so dnsmasq does not also inherit the upstreams from", + "# /etc/resolv.conf, which on a machine running this bridge may point back", + "# here and loop.", + "no-resolv", + `server=${host}#${port}`, + "", + ].join("\n"); +} + +/** + * The machine's real nameservers, for the bridge to forward to. + * + * Loopback entries are dropped: once routing points at this bridge, whatever + * wrote 127.0.0.53 into resolv.conf is the thing sending us the query, and + * forwarding back to it is a loop that ends in a timeout rather than an answer. + */ +export function parseUpstreams(resolvConf) { + const out = []; + for (const line of String(resolvConf ?? "").split("\n")) { + const m = line.match(/^\s*nameserver\s+(\S+)/i); + if (!m) continue; + const address = m[1].replace(/%.*$/, ""); + if (!isIP(address)) continue; + if (/^127\./.test(address) || address === "::1") continue; + if (!out.includes(address)) out.push(address); + } + return out; +} + export function resolvedConf(tlds, { host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) { return [ "# Written by `moshcode dns install`. Routes Moshpit TLDs to the local", diff --git a/test/dns-catchall.test.mjs b/test/dns-catchall.test.mjs new file mode 100644 index 0000000..064bc64 --- /dev/null +++ b/test/dns-catchall.test.mjs @@ -0,0 +1,205 @@ +// Catch-all routing: the bridge as the machine's resolver rather than an oracle +// for a list of endings. +// +// Routing each claimed ending by name did not survive the registry growing — +// systemd-resolved caps how many search domains it accepts, took 1090 of 4586 +// alphabetically, and rejected the rest one journal line at a time while +// reporting success. `~.` is one entry that never grows. +// +// The bill for that is this file. Every lookup on the machine now arrives here, +// so the tests that matter are the ones about what the bridge must NOT answer. +import test from "node:test"; +import assert from "node:assert/strict"; +import dgram from "node:dgram"; + +import { + createServer, + dnsmasqCatchAllConf, + encodeName, + forwardQuery, + isOurs, + parseUpstreams, + resolvedCatchAllConf, + TYPE_A, +} from "../src/dns.mjs"; + +function query(name, { id = 0x1234, type = TYPE_A } = {}) { + const head = Buffer.alloc(12); + head.writeUInt16BE(id, 0); + head.writeUInt16BE(0x0100, 2); + head.writeUInt16BE(1, 4); + const tail = Buffer.alloc(4); + tail.writeUInt16BE(type, 0); + tail.writeUInt16BE(1, 2); + return Buffer.concat([head, encodeName(name), tail]); +} + +const okJson = (body) => async () => ({ ok: true, json: async () => body }); +const rcode = (b) => b.readUInt16BE(2) & 0x000f; +const answers = (b) => b.readUInt16BE(6); + +async function ask(server, name, type = TYPE_A) { + const client = dgram.createSocket("udp4"); + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no reply")), 5000); + client.once("message", (m) => { clearTimeout(timer); resolve(m); }); + client.send(query(name, { type }), server.port, "127.0.0.1"); + }); + } finally { + client.close(); + } +} + +/** A stand-in upstream that answers everything with one fixed A record. */ +async function fakeUpstream(address = "203.0.113.55") { + const socket = dgram.createSocket("udp4"); + socket.on("message", (msg, rinfo) => { + const head = Buffer.alloc(12); + head.writeUInt16BE(msg.readUInt16BE(0), 0); + head.writeUInt16BE(0x8180, 2); + head.writeUInt16BE(1, 4); + head.writeUInt16BE(1, 6); + let i = 12; + while (msg[i] !== 0) i += msg[i] + 1; + const question = msg.subarray(12, i + 5); + const answer = Buffer.alloc(12); + answer.writeUInt16BE(0xc00c, 0); + answer.writeUInt16BE(1, 2); + answer.writeUInt16BE(1, 4); + answer.writeUInt32BE(60, 6); + answer.writeUInt16BE(4, 10); + socket.send( + Buffer.concat([head, question, answer, Buffer.from(address.split(".").map(Number))]), + rinfo.port, rinfo.address, + ); + }); + await new Promise((r) => socket.bind(0, "127.0.0.1", r)); + return { port: socket.address().port, close: () => new Promise((d) => socket.close(d)) }; +} + +/* ------------------------------------------------------------ the safety gate */ + +test("a clearnet name is never ours, however much it looks like a Moshpit one", () => { + // The whole risk of catch-all routing in one assertion: `google.com` has + // exactly two labels, same as `blue.eggs`. Parsing alone would have the + // bridge answer for the internet. + const claimed = new Set(["eggs", "oranges", "hacker"]); + for (const name of ["google.com", "example.org", "news.ycombinator.com", "a.io"]) { + assert.equal(isOurs(name, claimed), false, name); + } + assert.equal(isOurs("blue.eggs", claimed), true); + assert.equal(isOurs("chovy.hacker", claimed), true); +}); + +test("an unknown ending set means not ours, never ours", () => { + // Failing this way costs a Moshpit name until the registry answers again. + // Failing the other way costs the whole internet on that machine. + for (const set of [null, undefined, new Set(), "eggs", []]) { + assert.equal(isOurs("blue.eggs", set), false, String(set)); + } +}); + +/* --------------------------------------------------------------- forwarding */ + +test("a clearnet lookup is relayed to the upstream and back", async (t) => { + const upstream = await fakeUpstream("203.0.113.55"); + t.after(() => upstream.close()); + const server = await createServer({ + port: 0, + upstreams: [`127.0.0.1#${upstream.port}`], + tldSet: new Set(["eggs"]), + fetchImpl: okJson({ name_registered: true, target: "203.0.113.7" }), + }); + t.after(() => server.close()); + + const reply = await ask(server, "google.com"); + assert.equal(rcode(reply), 0); + assert.equal(answers(reply), 1); + assert.deepEqual([...reply.subarray(reply.length - 4)], [203, 0, 113, 55], "the upstream's answer"); +}); + +test("a Moshpit name is answered here, not forwarded", async (t) => { + const upstream = await fakeUpstream("203.0.113.55"); + t.after(() => upstream.close()); + const server = await createServer({ + port: 0, + upstreams: [`127.0.0.1#${upstream.port}`], + tldSet: new Set(["eggs"]), + fetchImpl: okJson({ name_registered: true, target: "203.0.113.7" }), + }); + t.after(() => server.close()); + + const reply = await ask(server, "blue.eggs"); + assert.deepEqual([...reply.subarray(reply.length - 4)], [203, 0, 113, 7], "ours, not the upstream's"); +}); + +test("silent upstreams are SERVFAIL, never NXDOMAIN", async (t) => { + // "I could not find out" is retried elsewhere. "It does not exist" gets + // cached, and the name stays broken after the network comes back. + const server = await createServer({ + port: 0, + upstreams: ["127.0.0.1#1"], // nothing listens there + tldSet: new Set(["eggs"]), + forwardTimeoutMs: 300, + fetchImpl: okJson({ name_registered: false, target: null }), + }); + t.after(() => server.close()); + + const reply = await ask(server, "google.com"); + assert.equal(rcode(reply), 2, "SERVFAIL"); + assert.equal(answers(reply), 0); +}); + +test("with no upstreams configured the bridge behaves exactly as before", async (t) => { + // The per-ending deployment still works: nothing to forward to means answer + // only for what we are authoritative for. + const server = await createServer({ + port: 0, + fetchImpl: okJson({ name_registered: true, target: "203.0.113.7" }), + }); + t.after(() => server.close()); + + const reply = await ask(server, "blue.eggs"); + assert.deepEqual([...reply.subarray(reply.length - 4)], [203, 0, 113, 7]); +}); + +test("forwardQuery gives up rather than hanging on a dead upstream", async () => { + const started = Date.now(); + assert.equal(await forwardQuery(query("x.eggs"), "127.0.0.1#1", { timeoutMs: 200 }), null); + assert.ok(Date.now() - started < 4000, "returned promptly"); +}); + +/* ------------------------------------------------------------------- config */ + +test("the resolver config is one line that never grows", () => { + const conf = resolvedCatchAllConf({ port: 5354 }); + assert.match(conf, /^Domains=~\.$/m); + assert.match(conf, /^DNS=127\.0\.0\.1:5354$/m); + // The failure this replaces: 4586 endings on one line, of which the resolver + // silently kept 1090. + assert.ok(conf.length < 600, "no per-ending list to truncate"); +}); + +test("the dnsmasq config does not inherit upstreams that point back here", () => { + const conf = dnsmasqCatchAllConf({ port: 5354 }); + assert.match(conf, /^no-resolv$/m, "or dnsmasq loops through /etc/resolv.conf"); + assert.match(conf, /^server=127\.0\.0\.1#5354$/m); +}); + +test("loopback nameservers are dropped when finding upstreams", () => { + // Once routing points here, 127.0.0.53 is the thing asking us — forwarding + // back to it is a loop that ends in a timeout instead of an answer. + const resolv = [ + "# generated", + "nameserver 127.0.0.53", + "nameserver 67.207.67.3", + "nameserver 67.207.67.2", + "nameserver ::1", + "nameserver 2001:4860:4860::8888", + "options edns0", + ].join("\n"); + assert.deepEqual(parseUpstreams(resolv), ["67.207.67.3", "67.207.67.2", "2001:4860:4860::8888"]); + assert.deepEqual(parseUpstreams(""), []); + assert.deepEqual(parseUpstreams(null), []); +});