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); +});