From 1e31cec173a499c333bd2b02ecd1ff666df9a87e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 1 Aug 2026 11:31:46 +0000 Subject: [PATCH] feat: answer AAAA, and tell NODATA apart from NXDOMAIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A name pointed at an IPv6 address did not resolve at all. The bridge only looked a name up for A, and the v6 target went through the IPv4 encoder, came back null, and was reported as NXDOMAIN — so the name did not merely fail to answer, it was denied. Fixing that surfaced the second bug. "We have no address for this question" was being answered as "this name does not exist", and a resolver is entitled to apply that to every other record type for the same name. A name pointed at a v6 address therefore denied itself to the A query browsers send alongside the AAAA one, and a name that existed answered NXDOMAIN to TXT, MX and the HTTPS/SVCB query a browser asks beside every page load. So existence is now carried separately from having an address: A name with a v6 target NOERROR, 0 answers (was NXDOMAIN) AAAA name with a v6 target NOERROR, 1 answer (was NXDOMAIN) TXT/MX/HTTPS on a live name NOERROR, 0 answers (was NXDOMAIN) anything that is not a name NXDOMAIN (unchanged) Parked names still exist and still answer the parking address — denying them would break the parking page, which is the one case where "nobody holds this" and "this does not resolve" must not be the same answer. targetAddress digs the address out of what owners actually type (`[2606:...]:8080`, a leftover scheme, a trailing slash). Ports are dropped on this path deliberately: an address record has nowhere to put one, so a target naming a port cannot be served here at all. This is the change moshcode carries in its vendored copy of this module (moshcoder/moshcode#190, #192). Verified by comparing 133 behaviours across both implementations — buildResponse over every combination of query type, address family and existence, plus targetAddress and answerPolicy — byte-identical throughout. Co-Authored-By: Claude Opus 5 (1M context) --- lib/dns.mjs | 135 ++++++++++++++++++++++--- test/dns-ipv6.test.mjs | 218 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 338 insertions(+), 15 deletions(-) create mode 100644 test/dns-ipv6.test.mjs diff --git a/lib/dns.mjs b/lib/dns.mjs index b0ec500..1452a15 100644 --- a/lib/dns.mjs +++ b/lib/dns.mjs @@ -15,6 +15,7 @@ // testable without binding a port. import dgram from "node:dgram"; +import { isIP } from "node:net"; export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh"; export const DEFAULT_PARKING_HOST = "moshcoding.com"; @@ -25,7 +26,8 @@ export const DEFAULT_HOST = "127.0.0.1"; // somewhere. A stale A record is the one failure mode users cannot debug. export const DEFAULT_TTL = 30; -const TYPE_A = 1; +export const TYPE_A = 1; +export const TYPE_AAAA = 28; const CLASS_IN = 1; const RCODE_OK = 0; const RCODE_NXDOMAIN = 3; @@ -107,22 +109,77 @@ function ipv4(address) { return Buffer.from(bytes); } -/** Build an A-record response, or NXDOMAIN when `address` is null. */ -export function buildResponse(query, buf, address, ttl = DEFAULT_TTL) { +/** + * 16 bytes of AAAA rdata. + * + * `isIP` has already ruled on the grammar, so the work here is expanding what + * the text form is allowed to leave out: the `::` run of zero groups, and the + * trailing dotted-quad an IPv4-mapped address is written with. + */ +function ipv6(address) { + const raw = String(address).trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (isIP(raw) !== 6) return null; + + let text = raw; + const mapped = text.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) { + const octets = mapped[2].split(".").map(Number); + text = `${mapped[1]}${(((octets[0] << 8) | octets[1]) >>> 0).toString(16)}:${(((octets[2] << 8) | octets[3]) >>> 0).toString(16)}`; + } + + const [head, tail] = text.split("::"); + const left = head ? head.split(":").filter(Boolean) : []; + const right = tail ? tail.split(":").filter(Boolean) : []; + const groups = text.includes("::") + ? [...left, ...Array(8 - left.length - right.length).fill("0"), ...right] + : left; + if (groups.length !== 8 || groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null; + + const buf = Buffer.alloc(16); + groups.forEach((group, i) => buf.writeUInt16BE(parseInt(group, 16), i * 2)); + return buf; +} + +/** + * Build an address-record response for the family the query asked for. + * + * Three outcomes, and the difference between the last two is the whole reason + * this is not a one-liner. NXDOMAIN says the name does not exist, and a + * resolver is entitled to apply that to every record type at once. A name + * pointed at an IPv6 address *does* exist — it just has no A record — so the A + * query every browser sends alongside the AAAA one has to come back NOERROR + * with no answers. Answering NXDOMAIN there teaches the resolver the name is + * gone and takes the AAAA lookup down with it. + * + * `exists` is that distinction on its own. Holding an address implies the name + * exists, so it defaults to exactly that, but the reverse does not hold: a name + * can exist and have no address to hand back — because the question was for a + * type this bridge does not serve, or because the target is a hostname rather + * than an address. Those are NODATA, not NXDOMAIN. + */ +export function buildResponse(query, buf, address, ttl = DEFAULT_TTL, exists = Boolean(address)) { const question = buf.subarray(12, query.questionEnd); - const rdata = address ? ipv4(address) : null; + const wantsV6 = query.type === TYPE_AAAA; + const rdata = address ? (wantsV6 ? ipv6(address) : ipv4(address)) : null; + if (!rdata) { return Buffer.concat([ - header(query.id, { rcode: RCODE_NXDOMAIN, answers: 0, recursionDesired: query.recursionDesired }), + header(query.id, { + // The name is here, we just have nothing to say for this question: NODATA. + rcode: exists ? RCODE_OK : RCODE_NXDOMAIN, + answers: 0, + recursionDesired: query.recursionDesired, + }), question, ]); } + const answer = Buffer.alloc(12); answer.writeUInt16BE(0xc00c, 0); // pointer to the question's name - answer.writeUInt16BE(TYPE_A, 2); + answer.writeUInt16BE(wantsV6 ? TYPE_AAAA : TYPE_A, 2); answer.writeUInt16BE(CLASS_IN, 4); answer.writeUInt32BE(ttl, 6); - answer.writeUInt16BE(4, 10); + answer.writeUInt16BE(rdata.length, 10); return Buffer.concat([ header(query.id, { rcode: RCODE_OK, answers: 1, recursionDesired: query.recursionDesired }), question, @@ -205,10 +262,53 @@ export async function resolveName( * outage must not silently redirect every name on the machine to a parking page. */ export async function answerFor(name, options = {}) { - const { parkingAddress } = options; + const { address } = await answerPolicy(name, options); + return address; +} + +/** + * Whether the name exists, and the address to hand back if we have one. + * + * The two are separate questions and conflating them is what produced a name + * that answered "I exist" to A and "no such name" to TXT in the same second. + * `wantsAddress` is false for every question type this bridge does not serve — + * the name is still looked up, because the answer to "does it exist" decides + * between NODATA and NXDOMAIN, and a browser asks HTTPS/SVCB beside every A + * and AAAA. NXDOMAIN to that one denies the name for the whole page load. + */ +export async function answerPolicy(name, options = {}) { + const { parkingAddress, wantsAddress = true } = options; const result = await resolveName(name, options); - if (result.status === "live") return result.target; - if (result.status === "parked") return parkingAddress || null; + const exists = result.status === "live" || result.status === "parked"; + if (!exists || !wantsAddress) return { exists, address: null }; + if (result.status === "live") return { exists, address: targetAddress(result.target) }; + return { exists, address: parkingAddress || null }; +} + +/** + * The bare address inside a stored target, or null when there isn't one. + * + * Targets are typed by hand and come back from the registry as `2606:...`, + * `[2606:...]:8080`, `example.com`, or with a scheme still attached. A record + * carries an address and nothing else, so the port is dropped here — a name + * whose target names a non-default port cannot be served by the resolver path + * at all, because there is no way to say "port 8080" in an A or AAAA record and + * the browser will go to 80 regardless. A hostname target is null for the same + * reason: turning it into an address would mean this bridge doing clearnet DNS. + */ +export function targetAddress(target) { + const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, ""); + if (!raw) return null; + + const bracketed = raw.match(/^\[([0-9a-f:.]+)\](?::\d+)?$/i); + const host = bracketed ? bracketed[1] : raw; + if (isIP(host)) return host; + + const at = host.lastIndexOf(":"); + if (at > 0 && /^\d+$/.test(host.slice(at + 1))) { + const bare = host.slice(0, at); + if (isIP(bare) === 4) return bare; + } return null; } @@ -231,14 +331,19 @@ export function createServer(options = {}) { const query = parseQuery(msg); if (!query) return; // malformed, or a response — say nothing at all let address = null; - // Only A/IN questions can be answered with an address; everything else - // (AAAA, MX, TXT) gets an honest empty NOERROR/NXDOMAIN rather than a lie. - if (query.type === TYPE_A && query.class === CLASS_IN) { - address = await answerFor(query.name, options).catch(() => null); + let exists = false; + // Only address questions can be answered with an address; everything else + // (MX, TXT, HTTPS) gets an honest empty NOERROR rather than a lie. It still + // has to be looked up: a browser asks HTTPS/SVCB beside every A and AAAA, + // and NXDOMAIN to that one denies the name for the whole page load. + if (query.class === CLASS_IN) { + const wantsAddress = query.type === TYPE_A || query.type === TYPE_AAAA; + const policy = await answerPolicy(query.name, { ...options, wantsAddress }).catch(() => null); + if (policy) ({ exists, address } = policy); } onQuery({ name: query.name, type: query.type, address }); try { - socket.send(buildResponse(query, msg, address, ttl), rinfo.port, rinfo.address); + socket.send(buildResponse(query, msg, address, ttl, exists), rinfo.port, rinfo.address); } catch { /* client vanished — nothing useful to do */ } diff --git a/test/dns-ipv6.test.mjs b/test/dns-ipv6.test.mjs new file mode 100644 index 0000000..d0d8e56 --- /dev/null +++ b/test/dns-ipv6.test.mjs @@ -0,0 +1,218 @@ +// IPv6 targets, and the NXDOMAIN-vs-NODATA distinction they forced. +// +// A name pointed at an IPv6 address did not resolve at all: the bridge only +// answered A, and the v6 target went through the IPv4 encoder, came back null, +// and was reported as "no such name". Fixing that surfaced the second bug — +// "we have no address for this question" was being answered as "this name does +// not exist", which a resolver is entitled to apply to every other record type +// for the same name. +import test from "node:test"; +import assert from "node:assert/strict"; +import dgram from "node:dgram"; + +import { + answerFor, + answerPolicy, + buildResponse, + createServer, + encodeName, + parseQuery, + targetAddress, + TYPE_A, + TYPE_AAAA, +} from "../lib/dns.mjs"; + +function query(name, { id = 0x1234, type = TYPE_A, cls = 1, rd = true } = {}) { + const head = Buffer.alloc(12); + head.writeUInt16BE(id, 0); + head.writeUInt16BE(rd ? 0x0100 : 0, 2); + head.writeUInt16BE(1, 4); + const tail = Buffer.alloc(4); + tail.writeUInt16BE(type, 0); + tail.writeUInt16BE(cls, 2); + return Buffer.concat([head, encodeName(name), tail]); +} + +const okJson = (body) => async () => ({ ok: true, json: async () => body }); +const rcode = (reply) => reply.readUInt16BE(2) & 0x000f; +const answers = (reply) => reply.readUInt16BE(6); + +/* ---------------------------------------------------------------- encoding */ + +test("an AAAA query is answered with 16 bytes of address", () => { + const buf = query("a.eggs", { type: TYPE_AAAA }); + const res = buildResponse(parseQuery(buf), buf, "2606:4700:4700::1111", 30); + assert.equal(answers(res), 1); + assert.equal(rcode(res), 0); + assert.deepEqual([...res.subarray(res.length - 16)], [ + 0x26, 0x06, 0x47, 0x00, 0x47, 0x00, 0, 0, + 0, 0, 0, 0, 0, 0, 0x11, 0x11, + ]); + // The answer must claim AAAA, not the A it was copied from. + assert.equal(res.readUInt16BE(res.length - 16 - 12 + 2), TYPE_AAAA); +}); + +test("the :: run expands to exactly the zero groups it stands for", () => { + const cases = { + "::1": [...new Array(15).fill(0), 1], + "::": new Array(16).fill(0), + "2001:db8::": [0x20, 0x01, 0x0d, 0xb8, ...new Array(12).fill(0)], + "2001:db8:0:0:0:0:0:1": [0x20, 0x01, 0x0d, 0xb8, ...new Array(11).fill(0), 1], + "fe80::1": [0xfe, 0x80, ...new Array(13).fill(0), 1], + }; + for (const [address, bytes] of Object.entries(cases)) { + const buf = query("a.eggs", { type: TYPE_AAAA }); + const res = buildResponse(parseQuery(buf), buf, address, 30); + assert.deepEqual([...res.subarray(res.length - 16)], bytes, address); + } +}); + +test("a v4 address is still a v4 answer, byte for byte as before", () => { + const buf = query("a.eggs"); + const res = buildResponse(parseQuery(buf), buf, "203.0.113.7", 30); + assert.equal(answers(res), 1); + assert.deepEqual([...res.subarray(res.length - 4)], [203, 0, 113, 7]); + assert.equal(res.readUInt16BE(res.length - 4 - 12 + 2), TYPE_A); +}); + +/* ------------------------------------------------- exists versus has-address */ + +test("an A query for a v6-only name is NODATA, not NXDOMAIN", () => { + // Browsers ask A and AAAA together. NXDOMAIN on the A half says the name + // does not exist, which is entitled to take the AAAA answer down with it. + const buf = query("a.eggs", { type: TYPE_A }); + const res = buildResponse(parseQuery(buf), buf, "2606:4700:4700::1111", 30); + assert.equal(answers(res), 0); + assert.equal(rcode(res), 0, "the name exists"); +}); + +test("a name nobody holds is NXDOMAIN in both families", () => { + for (const type of [TYPE_A, TYPE_AAAA]) { + const buf = query("a.eggs", { type }); + const res = buildResponse(parseQuery(buf), buf, null); + assert.equal(rcode(res), 3, `type ${type}`); + } +}); + +test("exists is carried separately from having an address", () => { + const buf = query("a.eggs", { type: 16 }); // TXT + const parsed = parseQuery(buf); + assert.equal(rcode(buildResponse(parsed, buf, null, 30, true)), 0, "exists → NODATA"); + assert.equal(rcode(buildResponse(parsed, buf, null, 30, false)), 3, "absent → NXDOMAIN"); +}); + +/* ------------------------------------------------------------------ policy */ + +test("targetAddress digs the address out of what owners actually type", () => { + assert.equal(targetAddress("2606:4700:4700::1111"), "2606:4700:4700::1111"); + assert.equal(targetAddress("[2606:4700:4700::1111]:8080"), "2606:4700:4700::1111"); + assert.equal(targetAddress("http://[2606:4700::1]/"), "2606:4700::1"); + assert.equal(targetAddress("203.0.113.7"), "203.0.113.7"); + assert.equal(targetAddress("203.0.113.7:8080"), "203.0.113.7"); + // A hostname is not an address, and resolving it here would mean this bridge + // doing clearnet DNS on behalf of whoever typed it. + assert.equal(targetAddress("box.example.com"), null); + assert.equal(targetAddress(""), null); + assert.equal(targetAddress(null), null); +}); + +test("answerFor hands back a bare address, not the stored target", async () => { + const address = await answerFor("a.eggs", { + fetchImpl: okJson({ name_registered: true, target: "[2606:4700:4700::1111]:8080" }), + }); + assert.equal(address, "2606:4700:4700::1111"); +}); + +test("answerPolicy reports existence for questions it cannot answer", async () => { + const live = okJson({ name_registered: true, target: "2606:4700::1" }); + + assert.deepEqual(await answerPolicy("a.eggs", { fetchImpl: live }), + { exists: true, address: "2606:4700::1" }); + // The name still exists; we simply serve no TXT for it. + assert.deepEqual(await answerPolicy("a.eggs", { fetchImpl: live, wantsAddress: false }), + { exists: true, address: null }); + + // "Nobody holds it" is not "it does not exist". An unclaimed but well-formed + // name under a known ending is parked, and the bridge answers the parking + // address for it — so it exists as far as DNS is concerned. Only something + // that is not a Moshpit name at all is absent. + const unheld = okJson({ registered: false, name_registered: false, target: null }); + assert.deepEqual(await answerPolicy("a.eggs", { fetchImpl: unheld, parkingAddress: "198.51.100.9" }), + { exists: true, address: "198.51.100.9" }); + assert.deepEqual(await answerPolicy("nodots", { fetchImpl: unheld }), + { exists: false, address: null }); +}); + +/* -------------------------------------------------------------- over UDP */ + +/** Ask the running server one question and hand back the raw reply. */ +async function ask(server, name, type) { + const client = dgram.createSocket("udp4"); + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no reply")), 5000); + client.once("message", (msg) => { + clearTimeout(timer); + resolve(msg); + }); + client.send(query(name, { type }), server.port, "127.0.0.1"); + }); + } finally { + client.close(); + } +} + +test("a v6 name answers AAAA and NODATAs everything else, over the wire", async (t) => { + const server = await createServer({ + port: 0, + fetchImpl: okJson({ name_registered: true, target: "2606:4700:4700::1111" }), + }); + t.after(() => server.close()); + + const aaaa = await ask(server, "blue.eggs", TYPE_AAAA); + assert.equal(answers(aaaa), 1, "AAAA answered"); + assert.deepEqual([...aaaa.subarray(aaaa.length - 16).subarray(0, 4)], [0x26, 0x06, 0x47, 0x00]); + + // Every other question about the same name must agree that it exists. + for (const [label, type] of [["A", TYPE_A], ["TXT", 16], ["MX", 15], ["HTTPS", 65]]) { + const reply = await ask(server, "blue.eggs", type); + assert.equal(rcode(reply), 0, `${label} should be NODATA, not NXDOMAIN`); + assert.equal(answers(reply), 0, `${label} carries no answer`); + } +}); + +test("what is not a Moshpit name is NXDOMAIN over the wire, in every type", async (t) => { + const server = await createServer({ + port: 0, + fetchImpl: okJson({ registered: false, name_registered: false, target: null }), + }); + t.after(() => server.close()); + + // Two labels is the whole grammar, so neither of these is ours to answer — + // and saying so is the point, since claiming them would hijack real lookups. + for (const name of ["nodots", "too.many.labels"]) { + for (const type of [TYPE_A, TYPE_AAAA, 16]) { + assert.equal(rcode(await ask(server, name, type)), 3, `${name} type ${type}`); + } + } +}); + +test("an unclaimed name is parked, which is NODATA rather than absent", async (t) => { + // The distinction that the previous test is the other half of: parked names + // exist and answer, so denying them would break the parking page. + const server = await createServer({ + port: 0, + parkingAddress: "198.51.100.9", + fetchImpl: okJson({ registered: false, name_registered: false, target: null }), + }); + t.after(() => server.close()); + + const a = await ask(server, "nope.eggs", TYPE_A); + assert.equal(rcode(a), 0); + assert.deepEqual([...a.subarray(a.length - 4)], [198, 51, 100, 9], "parked → parking address"); + + // No AAAA for a v4 parking address, but the name is still there. + const aaaa = await ask(server, "nope.eggs", TYPE_AAAA); + assert.equal(rcode(aaaa), 0, "NODATA, not NXDOMAIN"); + assert.equal(answers(aaaa), 0); +});