diff --git a/src/dns.mjs b/src/dns.mjs index 1904d50..5cceebb 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -17,6 +17,7 @@ import dgram from "node:dgram"; import { isIP } from "node:net"; +import { Resolver } from "node:dns/promises"; export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh"; export const DEFAULT_PARKING_HOST = "moshcoding.com"; @@ -281,6 +282,48 @@ export function buildRecordResponse(query, buf, records = [], { ttl = DEFAULT_TT return Buffer.concat([head, question, ...encoded]); } +/** + * A CNAME answer, plus the leaf addresses when we could find them. + * + * 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, + ]); +} + /** * Build an address-record response for the family the query asked for. * @@ -495,8 +538,8 @@ export async function answerFor(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(/\/+$/, ""); @@ -514,6 +557,30 @@ export function targetAddress(target) { return null; } +/** + * 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, not an address — `seo.rank` targets `dev.profullstack.com` — and + * refusing to say so was the bug that made every such name look unregistered. + * A CNAME expresses exactly this and costs us no clearnet DNS: the client + * 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; +} + /** * Is an address question on this name worth a second look for a CNAME? * @@ -526,6 +593,89 @@ export function mayHaveCname({ exists, address }) { return Boolean(exists) && !address; } +/** + * Everything an address question needs, from a single registry lookup. + * + * 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 at all (addresses came only from `target`), and a hostname target + * produced nothing. Both surfaced as an authoritative NOERROR with no answers, + * which a client is entitled to treat as final: 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 on the machine comes + * through here. Only a name that has nothing to say yet is worth the second + * round trip — which is the same bargain the old path struck for CNAMEs, held + * to here so the common case did not get slower in exchange for being right. + */ +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"); +} + +/** + * The addresses a clearnet hostname holds, for finishing a CNAME chain. + * + * A bare CNAME is a legal answer and a useless one here. This bridge sets RA=0 + * — it offers no recursion — so a stub that receives a dangling CNAME has been + * told, in the same breath, that nobody will chase it. systemd-resolved reports + * that as a name with no address, which is indistinguishable from broken. + * + * Best-effort by design: the chain is a courtesy on top of a CNAME that is + * already correct, so an upstream that is slow or silent costs the extra + * records, never the answer. + */ +export async function resolveChain(hostname, { upstreams = [], wantsV6 = false, timeoutMs = 2000 } = {}) { + const servers = upstreams.map(resolverServer).filter(Boolean); + if (!hostname || !servers.length) return []; + try { + const resolver = new Resolver({ timeout: timeoutMs, tries: 1 }); + resolver.setServers(servers); + const found = await (wantsV6 ? resolver.resolve6(hostname) : resolver.resolve4(hostname)); + return Array.isArray(found) ? found : []; + } catch { + return []; + } +} + +/** An upstream in `1.2.3.4#5353` form, as node's resolver wants to read it. */ +function resolverServer(upstream) { + const [address, portText] = String(upstream).split("#"); + const family = isIP(address); + if (!family) return null; + const port = Number(portText) || 53; + return port === 53 ? address : `${family === 6 ? `[${address}]` : address}:${port}`; +} + /** * Start the bridge. Returns { port, address, close() }. * @@ -908,19 +1058,28 @@ export function createServer(options = {}) { }); } 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 the name through its own - // resolver — the only party here that may do clearnet DNS — instead of - // getting 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, { + if (!wantsAddress) { + // HTTPS/SVCB and friends: the name's existence is the whole answer, and + // getting it wrong here 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, limit: maxResponseBytes || UDP_SAFE_BYTES, }); + } else if (plan?.kind === "chain") { + const addresses = await resolveChain(plan.cname, { + upstreams, wantsV6: query.type === TYPE_AAAA, timeoutMs: forwardTimeoutMs, + }); + reply = buildChainResponse(query, msg, { cname: plan.cname, addresses, ttl }); + address = addresses[0] || plan.cname; + } else { + address = plan?.address || null; } } } diff --git a/src/doh.mjs b/src/doh.mjs index d988035..f6d6233 100644 --- a/src/doh.mjs +++ b/src/doh.mjs @@ -16,8 +16,9 @@ // keeps no per-query record of who asked what. import { - answerRecords, buildRecordResponse, buildResponse, capResponse, clientKey, createBanList, - createRateLimiter, forwardQuery, isOurs, answerPolicy, mayHaveCname, parseQuery, refusalReason, + addressAnswer, answerRecords, buildChainResponse, buildRecordResponse, buildResponse, + capResponse, clientKey, createBanList, resolveChain, + createRateLimiter, forwardQuery, isOurs, answerPolicy, parseQuery, refusalReason, RECORD_TYPES, TYPE_A, TYPE_AAAA, DEFAULT_TTL, UDP_SAFE_BYTES, } from "./dns.mjs"; @@ -172,26 +173,46 @@ export function createDohHandler({ } const wantsAddress = query.type === TYPE_A || query.type === TYPE_AAAA; - const policy = await answerPolicy(query.name, { - registryBase, fetchImpl, parkingAddress, wantsAddress, - }).catch(() => ({ exists: false, address: null })); - - // Same second look for a CNAME as the UDP path, on the same condition: only - // when the name is here and has nothing else to answer with. - const cname = wantsAddress && mayHaveCname(policy) - ? await answerRecords(query.name, { registryBase, fetchImpl, type: "CNAME" }) - .catch(() => ({ records: [] })) - : null; - - onQuery({ name: query.name, type: query.type, address: policy.address }); + if (!wantsAddress) { + const policy = await answerPolicy(query.name, { + registryBase, fetchImpl, parkingAddress, wantsAddress: false, + }).catch(() => ({ exists: false, address: null })); + onQuery({ name: query.name, type: query.type, address: null }); + return { + status: 200, + headers: { "content-type": DNS_MESSAGE, "cache-control": cacheControl(ttl) }, + body: buildResponse(query, decoded.message, null, ttl, policy.exists), + }; + } + + // The same plan the UDP path follows, for the same reason the split above + // has to stay a split: a published record or a hostname target must resolve + // identically here, or this endpoint reintroduces the gap it exists to close. + const plan = await addressAnswer(query.name, { + registryBase, fetchImpl, parkingAddress, wantsV6: query.type === TYPE_AAAA, + }).catch(() => ({ exists: false, kind: "nxdomain", records: [], address: null, cname: null })); + + const answer = async () => { + if (plan.kind === "records") { + return buildRecordResponse(query, decoded.message, plan.records, { + ttl, exists: plan.exists, limit: maxResponseBytes || UDP_SAFE_BYTES, + }); + } + if (plan.kind === "chain") { + const addresses = await resolveChain(plan.cname, { + upstreams, wantsV6: query.type === TYPE_AAAA, timeoutMs: forwardTimeoutMs, + }); + return buildChainResponse(query, decoded.message, { cname: plan.cname, addresses, ttl }); + } + return buildResponse(query, decoded.message, plan.address, ttl, plan.exists); + }; + + const encoded = await answer(); + onQuery({ name: query.name, type: query.type, address: plan.address || plan.cname || null }); return { status: 200, headers: { "content-type": DNS_MESSAGE, "cache-control": cacheControl(ttl) }, - body: cname?.records?.length - ? buildRecordResponse(query, decoded.message, cname.records, { - ttl, exists: policy.exists, limit: maxResponseBytes || UDP_SAFE_BYTES, - }) - : buildResponse(query, decoded.message, policy.address, ttl, policy.exists), + body: encoded, }; }; } diff --git a/test/dns-address-answer.test.mjs b/test/dns-address-answer.test.mjs new file mode 100644 index 0000000..eacfd23 --- /dev/null +++ b/test/dns-address-answer.test.mjs @@ -0,0 +1,295 @@ +/** + * The address a Moshpit name actually has. + * + * Two gaps here made most of the registry look unregistered, and both failed + * in the same invisible shape: an authoritative NOERROR with no answers, which + * a client may treat as final. `dig` said the name existed; nothing could reach + * it; no log anywhere reported an error. + * + * - 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 + * + * So these tests read answers back off the wire. 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, + resolveChain, targetHostname, TYPE_A, TYPE_AAAA, TYPE_CNAME, +} from "../src/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); +const authoritative = (reply) => Boolean(reply.readUInt16BE(2) & 0x0400); + +/** 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 in a reply, with the owner name of each. + * + * A chain carries two different owners, so unlike the record decoder this one + * cannot assume the 0xc00c pointer — telling them apart is the point. + */ +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 is meant to skip + * that round trip whenever `target` already holds an address, and a fake that + * ignored 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 }; +} + +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", () => { + // A port cannot ride in a CNAME, and quietly dropping it 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: an + // address question was answered from `target` alone. + 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 reply = await ask(server, query("scrambled.eggs", { type: TYPE_AAAA })); + + assert.equal(rcode(reply), RCODE_OK); + const [record] = readAnswers(reply, "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 reply = await ask(server, query("scrambled.eggs", { type: TYPE_A })); + + const [record] = readAnswers(reply, "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) => { + // NXDOMAIN here would deny the name outright, taking the AAAA lookup the + // browser sent alongside it down too. + const server = await serve(t, registry({ + target: null, + records: [{ type: "AAAA", value: "2606:4700::1111" }], + })); + const reply = await ask(server, query("scrambled.eggs", { type: TYPE_A })); + 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.ok(authoritative(reply), "we are still authoritative for the ending"); + const [record] = readAnswers(reply, "scrambled.eggs"); + assert.equal(record.type, TYPE_CNAME); + assert.equal(readName(record.rdata, 0).name, "dev.profullstack.com"); +}); + +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. Asking for records it will not read + // costs the registry a second query per navigation. + 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((url) => url.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.equal(leaf.type, TYPE_A); + assert.deepEqual([...leaf.rdata], [67, 205, 189, 229]); +}); + +test("a chain nobody could complete is still a usable CNAME", () => { + // The leaf is a courtesy on top of an answer that is already correct, so an + // upstream that is slow or silent must cost the extra record and nothing more. + const buf = query("scrambled.eggs"); + const reply = buildChainResponse(parseQuery(buf), buf, { cname: "dev.profullstack.com", addresses: [] }); + + assert.equal(rcode(reply), RCODE_OK); + assert.equal(answers(reply), 1); +}); + +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"); +}); + +test("resolveChain does no clearnet DNS when there is nowhere to ask", async () => { + assert.deepEqual(await resolveChain("dev.profullstack.com", { upstreams: [] }), []); + assert.deepEqual(await resolveChain("", { upstreams: ["203.0.113.7"] }), []); +}); + +/* --------------------------------------------------------------- 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((url) => url.includes("records=1")).length, 0); +}); + +/** A registry that does not hold the name — the shape a 404 arrives in. */ +const missing = () => ({ fetchImpl: async () => ({ ok: false, json: async () => ({}) }), calls: [] }); + +test("addressAnswer denies a name the registry does not hold", async () => { + const plan = await addressAnswer("scrambled.eggs", { fetchImpl: missing().fetchImpl }); + assert.equal(plan.kind, "nxdomain"); + assert.equal(plan.exists, false); +}); + +test("a name the registry does not hold is still NXDOMAIN on an address question", 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 a name that exists. + const server = await serve(t, missing()); + const reply = await ask(server, query("scrambled.eggs")); + assert.equal(rcode(reply), RCODE_NXDOMAIN); +}); diff --git a/test/dns-nodata.test.mjs b/test/dns-nodata.test.mjs index e5f62a5..f55c762 100644 --- a/test/dns-nodata.test.mjs +++ b/test/dns-nodata.test.mjs @@ -110,13 +110,15 @@ test("the A question beside it still answers, so the pair does not contradict it /* ------------------------------------------------- a name with no address to serve */ -test("a live name pointed at a hostname is NODATA, not NXDOMAIN", async (t) => { - // targetAddress() refuses a hostname on purpose — the bridge does not do - // clearnet DNS. The name still exists, so denying it is the wrong answer. +test("a live name pointed at a hostname answers with a CNAME, not an empty NOERROR", async (t) => { + // Most of the registry is pointed at a host rather than an address, so the + // old empty-NOERROR here was not an edge case: it was the common case, and + // it read to every client as a name with nothing behind it. An A record + // cannot hold a hostname; a CNAME can, and the client chases it. const server = await serve(t, live("example.com")); const reply = await ask(server, query("scrambled.eggs", { type: TYPE_A })); assert.equal(rcode(reply), RCODE_OK); - assert.equal(answers(reply), 0); + assert.equal(answers(reply), 1, "the name has somewhere to go and must say so"); }); test("a live name whose target names a port is NODATA, not NXDOMAIN", async (t) => {