diff --git a/lib/dns.mjs b/lib/dns.mjs index d08dd3a..2860927 100644 --- a/lib/dns.mjs +++ b/lib/dns.mjs @@ -288,6 +288,48 @@ export function buildRecordResponse(query, buf, records = [], { ttl = DEFAULT_TT * type this bridge does not serve, or because the target is a hostname rather * than an address. Those are NODATA, not NXDOMAIN. */ +/** + * A CNAME answer, plus the leaf addresses when the caller found any. + * + * Two owner names appear in one message: the question's name owns the CNAME, + * and the CNAME's target owns the addresses. Only the first can use the 0xc00c + * pointer — it is the only name already in the message — so the target is + * written out in full for each leaf. Uncompressed is legal, and a handful of + * spare bytes is a fair price for not hand-rolling a compression table. + */ +export function buildChainResponse(query, buf, { cname, addresses = [], ttl = DEFAULT_TTL } = {}) { + const question = buf.subarray(12, query.questionEnd); + const wantsV6 = query.type === TYPE_AAAA; + const target = encodeName(cname); + + const head = Buffer.alloc(12); + head.writeUInt16BE(0xc00c, 0); // the question's name, by pointer + head.writeUInt16BE(TYPE_CNAME, 2); + head.writeUInt16BE(CLASS_IN, 4); + head.writeUInt32BE(ttl, 6); + head.writeUInt16BE(target.length, 10); + + const parts = [head, target]; + let answers = 1; + for (const address of addresses) { + const rdata = wantsV6 ? ipv6(address) : ipv4(address); + if (!rdata) continue; + const leaf = Buffer.alloc(10); + leaf.writeUInt16BE(wantsV6 ? TYPE_AAAA : TYPE_A, 0); + leaf.writeUInt16BE(CLASS_IN, 2); + leaf.writeUInt32BE(ttl, 4); + leaf.writeUInt16BE(rdata.length, 8); + parts.push(target, leaf, rdata); + answers += 1; + } + + return Buffer.concat([ + header(query.id, { rcode: RCODE_OK, answers, recursionDesired: query.recursionDesired }), + question, + ...parts, + ]); +} + export function buildResponse(query, buf, address, ttl = DEFAULT_TTL, exists = Boolean(address)) { const question = buf.subarray(12, query.questionEnd); const wantsV6 = query.type === TYPE_AAAA; @@ -435,6 +477,82 @@ export function mayHaveCname({ exists, address }) { return Boolean(exists) && !address; } +/** + * The bare hostname inside a stored target, or null when there isn't one. + * + * The other half of `targetAddress`. Most names in the registry are pointed at + * a host rather than an address — `seo.rank` targets `dev.profullstack.com` — + * and refusing to say so was the bug that made every such name look + * unregistered: the bridge answered an authoritative NOERROR with no records, + * which a client is entitled to treat as final. + * + * A CNAME expresses exactly this, and it costs this bridge no clearnet DNS. It + * is routed per-TLD, so the target is not a name that comes back here — the + * machine's own resolver chases it, which is what a CNAME is for. + * + * A target naming a port is null on purpose. No CNAME can carry `:8080`, and + * sending the client to port 80 of the right host is a worse answer than + * admitting there is nothing here to say. + */ +export function targetHostname(target) { + const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, ""); + if (!raw || targetAddress(raw)) return null; + // A colon is a port or a malformed v6 literal; a slash is a path. Neither + // survives the trip into an owner name, so neither is guessed at. + if (raw.includes(":") || raw.includes("/")) return null; + const host = raw.toLowerCase().replace(/\.$/, ""); + const label = "[a-z0-9]([a-z0-9-]*[a-z0-9])?"; + return new RegExp(`^${label}(\\.${label})+$`).test(host) ? host : null; +} + +/** + * Everything an address question needs, from the registry. + * + * The old path asked two separate questions — `answerPolicy` for the target, + * then `answerRecords` for a CNAME — and between them dropped the two cases + * that cover most of the registry. A published A/AAAA record was never + * consulted for an address question, because addresses came only from `target`; + * and a `target` naming a host produced nothing at all. Both surfaced as an + * authoritative NOERROR with no answers, so the name looked dead while the + * registry held a perfectly good answer for it. + * + * The cheap question is asked first and usually ends it: a name pointed at a + * bare address needs no record set, and every page load comes through here. + * Only a name with nothing to say yet is worth the second round trip — the + * same bargain the old CNAME lookup already struck. + */ +export async function addressAnswer(name, options = {}) { + const { parkingAddress, wantsV6 = false } = options; + const plan = (kind, extra) => ({ exists: true, kind, records: [], address: null, cname: null, ...extra }); + + const result = await resolveName(name, options); + const exists = result.status === "live" || result.status === "parked"; + if (!exists) return { exists: false, kind: "nxdomain", records: [], address: null, cname: null }; + + // Parking is checked before anything the registry published: a parked name's + // whole job is to reach the page explaining that it is for sale. + if (result.status === "parked") { + return parkingAddress ? plan("address", { address: parkingAddress }) : plan("nodata"); + } + + const address = targetAddress(result.target); + if (address) return plan("address", { address }); + + const full = await resolveName(name, { ...options, records: true }); + const of = (type) => (full.records || []).filter((r) => r?.type === type); + + // An address the owner published beats a CNAME to somewhere that holds one: + // it is the more specific statement, and it saves the client a lookup. + const published = of(wantsV6 ? "AAAA" : "A"); + if (published.length) return plan("records", { records: published }); + + const cnames = of("CNAME"); + if (cnames.length) return plan("records", { records: cnames }); + + const host = targetHostname(result.target); + return host ? plan("chain", { cname: host }) : plan("nodata"); +} + /* -------------------------------------------------------------------- server */ /** @@ -476,8 +594,8 @@ export async function answerPolicy(name, options = {}) { * 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. + * the browser will go to 80 regardless. A hostname target is null here because + * an A record cannot hold one; `targetHostname` is the other half of the answer. */ export function targetAddress(target) { const raw = String(target || "").trim().replace(/^https?:\/\//i, "").replace(/\/+$/, ""); @@ -529,17 +647,28 @@ export function createServer(options = {}) { reply = buildRecordResponse(query, msg, found?.records || [], { ttl, exists }); } else 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); - // A name that is here with no address to give may still have published a - // CNAME, which is the one record that can answer an address question. - // Handing it back lets the client chase it through its own resolver — the - // only party here that may do clearnet DNS — instead of the NODATA that - // made a pointed name look broken. - if (wantsAddress && mayHaveCname(policy || {})) { - const found = await answerRecords(query.name, { ...options, type: "CNAME" }).catch(() => null); - if (found?.records?.length) { - reply = buildRecordResponse(query, msg, found.records, { ttl, exists }); + if (!wantsAddress) { + // HTTPS/SVCB and friends: the name's existence is the whole answer, and + // getting it wrong denies the name for every other question too. + const policy = await answerPolicy(query.name, { ...options, wantsAddress: false }).catch(() => null); + if (policy) ({ exists } = policy); + } else { + const plan = await addressAnswer(query.name, { + ...options, wantsV6: query.type === TYPE_AAAA, + }).catch(() => null); + exists = Boolean(plan?.exists); + if (plan?.kind === "records") { + reply = buildRecordResponse(query, msg, plan.records, { ttl, exists }); + } else if (plan?.kind === "chain") { + // No leaf addresses are attached here. Unlike a catch-all bridge, this + // one is routed per-TLD, so the CNAME's target is not a name that + // comes back to it: the machine's own resolver — a full recursive one + // — chases it. Resolving it here would be this bridge doing clearnet + // DNS to answer a question the system can already answer itself. + reply = buildChainResponse(query, msg, { cname: plan.cname, ttl }); + address = plan.cname; + } else { + address = plan?.address || null; } } } diff --git a/test/dns-address-answer.test.mjs b/test/dns-address-answer.test.mjs new file mode 100644 index 0000000..c7f8f39 --- /dev/null +++ b/test/dns-address-answer.test.mjs @@ -0,0 +1,260 @@ +/** + * The address a Moshpit name actually has. + * + * Two gaps made most of the registry unresolvable, and both failed in the same + * invisible shape — an authoritative NOERROR with no answers, which a client is + * entitled to treat as final: + * + * - a published A/AAAA record was never consulted for an address question, + * because addresses came only from `target` + * - a `target` naming a host rather than an address produced nothing at all, + * and most of the registry points at a host + * + * `dig` reported the name as existing, nothing could reach it, and no log + * anywhere showed an error. In practice it surfaced as + * `curl: (6) Could not resolve host`. + * + * Answers are read back off the wire here. A reply of the right shape with the + * wrong bytes in it is exactly the failure being fixed. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import dgram from "node:dgram"; + +import { + addressAnswer, buildChainResponse, createServer, encodeName, parseQuery, + targetHostname, TYPE_A, TYPE_AAAA, TYPE_CNAME, +} from "../lib/dns.mjs"; + +const RCODE_OK = 0; +const RCODE_NXDOMAIN = 3; + +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 rcode = (reply) => reply.readUInt16BE(2) & 0x0f; +const answers = (reply) => reply.readUInt16BE(6); + +/** A name written out in full — the chain builder never emits a pointer for one. */ +function readName(buf, offset) { + const labels = []; + let i = offset; + for (;;) { + const len = buf[i]; + if (len === undefined) throw new Error("truncated name"); + if (len === 0) return { name: labels.join("."), offset: i + 1 }; + labels.push(buf.toString("ascii", i + 1, i + 1 + len)); + i += len + 1; + } +} + +/** Every answer, with its owner — a chain carries two different ones. */ +function readAnswers(reply, name) { + const found = []; + let i = 12 + encodeName(name).length + 4; + for (let n = 0; n < answers(reply); n++) { + let owner; + if (reply.readUInt16BE(i) === 0xc00c) { + owner = name; + i += 2; + } else { + ({ name: owner, offset: i } = readName(reply, i)); + } + const type = reply.readUInt16BE(i); + const ttl = reply.readUInt32BE(i + 4); + const length = reply.readUInt16BE(i + 8); + const rdata = reply.subarray(i + 10, i + 10 + length); + i += 10 + length; + found.push({ owner, type, ttl, rdata }); + } + return found; +} + +/** + * A registry that answers the record set only when it was asked for. + * + * The `&records=1` split is load-bearing: the address path skips that round + * trip whenever `target` already holds an address, and a fake ignoring the flag + * would hide a regression in exactly that. + */ +function registry({ target = null, records = [], registered = true } = {}) { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + const wants = url.includes("records=1"); + return { + ok: true, + json: async () => ({ name_registered: registered, target, ...(wants ? { records } : {}) }), + }; + }; + return { fetchImpl, calls }; +} + +const missing = () => ({ fetchImpl: async () => ({ ok: false, json: async () => ({}) }), calls: [] }); + +async function ask(server, buf) { + 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(buf, server.port, "127.0.0.1"); + }); + } finally { + client.close(); + } +} + +async function serve(t, { fetchImpl }, extra = {}) { + const server = await createServer({ port: 0, parkingAddress: "198.51.100.9", fetchImpl, ...extra }); + t.after(() => server.close()); + return server; +} + +/* ----------------------------------------------------------- what a target holds */ + +test("targetHostname reads the host out of a target that is not an address", () => { + assert.equal(targetHostname("dev.profullstack.com"), "dev.profullstack.com"); + assert.equal(targetHostname("https://dev.profullstack.com/"), "dev.profullstack.com"); + assert.equal(targetHostname("DEV.Profullstack.COM"), "dev.profullstack.com", "case is not identity"); + assert.equal(targetHostname("dev.profullstack.com."), "dev.profullstack.com", "a root dot is not a label"); +}); + +test("targetHostname refuses what no CNAME could carry", () => { + // Dropping a port quietly would send the client to port 80 of the right + // host — a wrong answer that looks right. + assert.equal(targetHostname("example.com:8080"), null); + assert.equal(targetHostname("https://example.com/path"), null, "a path is not a name"); + assert.equal(targetHostname("203.0.113.7"), null, "an address is targetAddress's job"); + assert.equal(targetHostname("2606:4700::1111"), null); + assert.equal(targetHostname("localhost"), null, "a single label is not a resolvable target"); + assert.equal(targetHostname(""), null); + assert.equal(targetHostname(null), null); +}); + +/* ------------------------------------------------- a record the owner published */ + +test("a published AAAA record answers the AAAA question", async (t) => { + // The registry held this the whole time and the bridge never looked. + const server = await serve(t, registry({ + target: "dev.profullstack.com", + records: [{ type: "AAAA", value: "2604:a880:400:d1:0:4:c3fe:1", ttl: 300 }], + })); + const [record] = readAnswers(await ask(server, query("scrambled.eggs", { type: TYPE_AAAA })), "scrambled.eggs"); + + assert.equal(record.type, TYPE_AAAA); + assert.equal(record.ttl, 300, "the owner's TTL, not the bridge's default"); + assert.equal(record.rdata.length, 16); + assert.equal(record.rdata.readUInt16BE(0), 0x2604); +}); + +test("a published A record answers the A question", async (t) => { + const server = await serve(t, registry({ + target: "dev.profullstack.com", + records: [{ type: "A", value: "203.0.113.7", ttl: 120 }], + })); + const [record] = readAnswers(await ask(server, query("scrambled.eggs")), "scrambled.eggs"); + assert.equal(record.type, TYPE_A); + assert.deepEqual([...record.rdata], [203, 0, 113, 7]); +}); + +test("a name with only an AAAA record still exists to the A question", async (t) => { + const server = await serve(t, registry({ target: null, records: [{ type: "AAAA", value: "2606:4700::1111" }] })); + const reply = await ask(server, query("scrambled.eggs")); + assert.equal(rcode(reply), RCODE_OK, "the name is here, it just has no A"); +}); + +/* ------------------------------------------------------- a target that is a host */ + +test("a hostname target is answered as a CNAME to that host", async (t) => { + const server = await serve(t, registry({ target: "dev.profullstack.com" })); + const reply = await ask(server, query("scrambled.eggs")); + + assert.equal(rcode(reply), RCODE_OK); + assert.equal(answers(reply), 1); + const [record] = readAnswers(reply, "scrambled.eggs"); + assert.equal(record.type, TYPE_CNAME); + assert.equal(readName(record.rdata, 0).name, "dev.profullstack.com"); +}); + +test("the chain is left for the machine's own resolver to finish", async (t) => { + // This bridge is routed per-TLD, so the CNAME's target is not a name that + // comes back here — the system resolver chases it. Resolving it ourselves + // would be this bridge doing clearnet DNS to answer a question the machine + // can already answer, which is the thing it deliberately does not do. + const reg = registry({ target: "dev.profullstack.com" }); + const server = await serve(t, reg); + await ask(server, query("scrambled.eggs")); + assert.ok(reg.calls.every((u) => u.includes("pit") || u.includes("resolve")), + "the registry is the only thing this bridge talks to"); +}); + +test("a target naming a port stays NODATA rather than lying about the port", async (t) => { + const server = await serve(t, registry({ target: "example.com:8080" })); + const reply = await ask(server, query("scrambled.eggs")); + assert.equal(rcode(reply), RCODE_OK); + assert.equal(answers(reply), 0); +}); + +test("an address in the target still short-circuits the record lookup", async (t) => { + // The fast path every page load takes. + const reg = registry({ target: "203.0.113.7" }); + const server = await serve(t, reg); + await ask(server, query("scrambled.eggs")); + assert.equal(reg.calls.filter((u) => u.includes("records=1")).length, 0); +}); + +/* ------------------------------------------------------------- the chain on the wire */ + +test("a completed chain carries the CNAME and the leaf under their own owners", () => { + const name = "scrambled.eggs"; + const buf = query(name); + const reply = buildChainResponse(parseQuery(buf), buf, { + cname: "dev.profullstack.com", addresses: ["67.205.189.229"], + }); + + assert.equal(answers(reply), 2); + const [cname, leaf] = readAnswers(reply, name); + assert.equal(cname.owner, name, "the CNAME is owned by the name that was asked about"); + assert.equal(cname.type, TYPE_CNAME); + assert.equal(leaf.owner, "dev.profullstack.com", "the address is owned by the CNAME's target"); + assert.deepEqual([...leaf.rdata], [67, 205, 189, 229]); +}); + +test("a leaf of the wrong family is dropped, not encoded as garbage", () => { + const buf = query("scrambled.eggs", { type: TYPE_AAAA }); + const reply = buildChainResponse(parseQuery(buf), buf, { + cname: "dev.profullstack.com", addresses: ["67.205.189.229"], + }); + assert.equal(answers(reply), 1, "an IPv4 address cannot answer an AAAA question"); +}); + +/* --------------------------------------------------------------- the plan itself */ + +test("addressAnswer parks a claimed name before reading any record", async () => { + const reg = registry({ target: null }); + const plan = await addressAnswer("scrambled.eggs", { + fetchImpl: reg.fetchImpl, parkingAddress: "198.51.100.9", + }); + assert.equal(plan.kind, "address"); + assert.equal(plan.address, "198.51.100.9"); + assert.equal(reg.calls.filter((u) => u.includes("records=1")).length, 0); +}); + +test("a name the registry does not hold is still NXDOMAIN", async (t) => { + // The one answer the new paths must never swallow: adding CNAMEs and record + // lookups above this must not turn a name nobody holds into one that exists. + const server = await serve(t, missing()); + assert.equal(rcode(await ask(server, query("scrambled.eggs"))), RCODE_NXDOMAIN); +});