diff --git a/src/daemon/bnsd.js b/src/daemon/bnsd.js index 242e852..1f7da34 100644 --- a/src/daemon/bnsd.js +++ b/src/daemon/bnsd.js @@ -20,6 +20,7 @@ // --bind defaults to loopback. A PUBLIC gateway must pass --bind 0.0.0.0 // explicitly; --dns-bind is deliberately separate so that doing so does not also // expose the DNS server (an open resolver is a DDoS amplification relay). +import dgram from "dgram"; import https from "node:https"; import tls from "node:tls"; import { Readable } from "node:stream"; @@ -30,7 +31,7 @@ import WebSocket from "ws"; import { buildIndex, normalizeName } from "../lib/resolver-web.js"; import { issueCert } from "../lib/ca.js"; import { isPermittedHost } from "../lib/tlds.js"; -import { startDnsServer } from "../lib/dns-server.js"; +import { parseQuestion, buildReply, forwardQuery } from "../lib/dns-server.js"; // ---------- configuration ---------- // Effective value = CLI flag, else config file, else built-in default. So the @@ -84,63 +85,28 @@ async function refresh() { await refresh(); setInterval(refresh, 30_000); -// ---------- minimal DNS wire helpers ---------- -function parseQuestion(msg) { - let i = 12; - const labels = []; - while (i < msg.length) { - const len = msg[i]; - if (len === 0) { i += 1; break; } - if (len > 63) return null; - labels.push(msg.subarray(i + 1, i + 1 + len).toString("ascii")); - i += 1 + len; - } - if (i + 4 > msg.length) return null; - const qtype = msg.readUInt16BE(i); - return { name: labels.join(".").toLowerCase(), qtype, qend: i + 4 }; +// DNS wire helpers live in ../lib/dns-server.js. Two rules the previous inline +// versions got wrong (found 2026-08-01; see test/dns-server.test.mjs): +// 1. buildReply MUST echo the query's EDNS OPT record (RFC 6891 §6.1.1) — +// strict clients (glibc getaddrinfo, Firefox trr-off) treat a missing OPT +// on an EDNS-annotated query as FORMERR and surface EAI_FAIL / "Server +// Not Found". Lenient clients (nslookup, Chrome async-dns) tolerate it, +// which is why the bug looked flaky. +// 2. NODATA on a known name (e.g. AAAA on a chain-A) must also echo OPT. +// Both are handled by buildReply(query, qend, {answers, ...}) — pass answers +// as [] for NODATA. forwardQuery is a Promise-returning wrapper around the +// per-query UDP relay socket. +async function forward(msg, rinfo, server) { + try { + const resp = await forwardQuery(msg, UPSTREAM, { timeoutMs: 3000 }); + server.send(resp, rinfo.port, rinfo.address); + } catch { /* silent drop — client retries */ } } -function reply(query, qend, { rcode = 0, answers = [] }) { - const question = query.subarray(12, qend); - const header = Buffer.alloc(12); - query.copy(header, 0, 0, 2); - header.writeUInt16BE(0x8580 | rcode, 2); - header.writeUInt16BE(1, 4); - header.writeUInt16BE(answers.length, 6); - const parts = [header, question]; - for (const ip of answers) { - const rr = Buffer.alloc(16); - rr.writeUInt16BE(0xc00c, 0); - rr.writeUInt16BE(1, 2); - rr.writeUInt16BE(1, 4); - rr.writeUInt32BE(TTL, 6); - rr.writeUInt16BE(4, 10); - const o = ip.split(".").map(Number); - for (let j = 0; j < 4; j++) rr[12 + j] = o[j]; - parts.push(rr); - } - return Buffer.concat(parts); -} -// Forward a DNS query upstream. When `onResp` is passed we hand the raw response -// buffer to it instead of returning it to the client -- lets icann-first inspect -// the reply's rcode and choose to substitute a BCNR answer on NXDOMAIN. -function forward(msg, rinfo, server, onResp) { - const sock = dgram.createSocket("udp4"); - const timer = setTimeout(() => { - sock.close(); - if (onResp) onResp(new Error("upstream timeout")); - }, 3000); - sock.on("message", (resp) => { - clearTimeout(timer); - if (onResp) onResp(null, resp); - else server.send(resp, rinfo.port, rinfo.address); - sock.close(); - }); - sock.on("error", (e) => { - clearTimeout(timer); - try { sock.close(); } catch {} - if (onResp) onResp(e); - }); - sock.send(msg, 53, UPSTREAM); +async function forwardInspect(msg, rinfo, server, onResp) { + try { + const resp = await forwardQuery(msg, UPSTREAM, { timeoutMs: 3000 }); + onResp(null, resp); + } catch (e) { onResp(e); } } // A busy port disables just that server, not the whole daemon. @@ -154,12 +120,14 @@ function warnPort(label, port, e) { const dns = dgram.createSocket("udp4"); dns.on("error", (e) => warnPort("DNS", DNS_PORT, e)); // Compose the BCNR answer for a resolved entry -- shared by both policies. +// buildReply echoes the query's EDNS OPT record when present (strict clients +// require this); pass answers=[] for NODATA on non-A qtypes. function bcnrAnswerFor(msg, q, entry) { if (q.qtype === 1) { // A const ip = entry.records.ip ?? "127.0.0.1"; // no ip -> local gateway serves it - return reply(msg, q.qend, { answers: [ip] }); + return buildReply(msg, q.qend, { answers: [ip], ttl: TTL }); } - return reply(msg, q.qend, {}); // known name, other qtype + return buildReply(msg, q.qend, { answers: [], ttl: TTL }); // known name, other qtype } dns.on("message", (msg, rinfo) => { const q = parseQuestion(msg); @@ -170,10 +138,10 @@ dns.on("message", (msg, rinfo) => { if (EFFECTIVE_POLICY === "icann-first") { // Forward to ICANN first; only use BCNR when ICANN says NXDOMAIN (rcode 3) // AND we have a registered entry for this name. - return forward(msg, rinfo, dns, (err, resp) => { + return forwardInspect(msg, rinfo, dns, (err, resp) => { if (err) { // Upstream unreachable -- fall back to BCNR if we can answer, else SERVFAIL. - const ans = entry ? bcnrAnswerFor(msg, q, entry) : reply(msg, q.qend, { rcode: 2 }); + const ans = entry ? bcnrAnswerFor(msg, q, entry) : buildReply(msg, q.qend, { rcode: 2, ttl: TTL }); return dns.send(ans, rinfo.port, rinfo.address); } // DNS response rcode = low 4 bits of byte 3 in the header. diff --git a/src/lib/dns-server.js b/src/lib/dns-server.js index ce3982a..2977a99 100644 --- a/src/lib/dns-server.js +++ b/src/lib/dns-server.js @@ -190,14 +190,17 @@ export function buildFormErr(query) { return header; // QD/AN/NS/AR all zero } -// Byte-for-byte UDP relay to `upstream:53`. Returns a promise that resolves -// with the upstream response bytes so the caller can send them AND so tests -// can inspect them. Times out at `timeoutMs`. +// Byte-for-byte UDP relay to the upstream. `upstream` is "host" (defaults to +// port 53) or "host:port". Returns a promise that resolves with the response +// bytes so the caller can send them back and tests can inspect them. Times +// out at `timeoutMs`. // // The socket has ONE listener that fires once, then closes. There is no // address/id validation because the socket is single-use and ephemerally // bound — only the correct upstream knows the port and will reply to it. export function forwardQuery(msg, upstream, { timeoutMs = 4000 } = {}) { + const [host, portStr] = String(upstream).split(":"); + const port = portStr ? Number(portStr) : 53; return new Promise((resolve, reject) => { const sock = dgram.createSocket("udp4"); let done = false; @@ -211,7 +214,7 @@ export function forwardQuery(msg, upstream, { timeoutMs = 4000 } = {}) { const timer = setTimeout(() => finish(new Error("forward timeout")), timeoutMs); sock.on("message", (resp) => finish(null, resp)); sock.on("error", (e) => finish(e)); - sock.send(msg, 53, upstream, (err) => { if (err) finish(err); }); + sock.send(msg, port, host, (err) => { if (err) finish(err); }); }); } diff --git a/test/dns-server.test.mjs b/test/dns-server.test.mjs new file mode 100644 index 0000000..c3b9670 --- /dev/null +++ b/test/dns-server.test.mjs @@ -0,0 +1,321 @@ +// Wire-format tests for the bnsd DNS server. +// +// The bug we chase: strict resolvers (Node getaddrinfo, Firefox trr-off) fail +// against the shipped daemon while lenient ones (nslookup, Chrome async-dns) +// work. The suspect is EDNS0 OPT handling and how the daemon frames NODATA. +// +// Each test constructs a real DNS query on the wire, drives the server, and +// parses the response. When possible we also drive Node's `dns.Resolver` +// against the running server — it is UDP-level like getaddrinfo, so if it +// rejects the response the shipped daemon has the same fault under real +// clients. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import dgram from "node:dgram"; +import { Resolver } from "node:dns/promises"; +import { + parseQuestion, + extractOpt, + buildReply, + handleQuery, + startDnsServer, +} from "../src/lib/dns-server.js"; + +// --- helpers to build/parse DNS packets on the wire --------------------------- + +function encodeName(name) { + const parts = name.split(".").filter(Boolean); + let len = 1; + for (const p of parts) len += 1 + p.length; + const out = Buffer.alloc(len); + let i = 0; + for (const p of parts) { + out[i++] = p.length; + out.write(p, i, "ascii"); + i += p.length; + } + out[i] = 0; + return out; +} + +// Build a minimal DNS query. `edns` adds an OPT RR in the additional section +// with the given udpPayloadSize (default 1232 — what modern glibc/Firefox use). +function buildQuery(name, qtype, { id = 0x1234, rd = true, edns = false, udpPayloadSize = 1232 } = {}) { + const header = Buffer.alloc(12); + header.writeUInt16BE(id, 0); + header.writeUInt16BE(rd ? 0x0100 : 0x0000, 2); + header.writeUInt16BE(1, 4); // QDCOUNT + header.writeUInt16BE(0, 6); // ANCOUNT + header.writeUInt16BE(0, 8); // NSCOUNT + header.writeUInt16BE(edns ? 1 : 0, 10); // ARCOUNT + const qname = encodeName(name); + const qtc = Buffer.alloc(4); + qtc.writeUInt16BE(qtype, 0); + qtc.writeUInt16BE(1, 2); // QCLASS = IN + const parts = [header, qname, qtc]; + if (edns) { + // OPT RR: name=root(0), type=41, class=udpPayloadSize, TTL=0 (extRcode|ver|flags), rdlength=0 + const opt = Buffer.alloc(11); + opt[0] = 0; // root name + opt.writeUInt16BE(41, 1); // TYPE = OPT + opt.writeUInt16BE(udpPayloadSize, 3); + opt.writeUInt32BE(0, 5); // TTL / extended rcode etc + opt.writeUInt16BE(0, 9); // RDLENGTH + parts.push(opt); + } + return Buffer.concat(parts); +} + +function parseHeader(msg) { + return { + id: msg.readUInt16BE(0), + flags: msg.readUInt16BE(2), + qr: (msg[2] >> 7) & 1, + aa: (msg[2] >> 2) & 1, + tc: (msg[2] >> 1) & 1, + rd: msg[2] & 1, + ra: (msg[3] >> 7) & 1, + rcode: msg[3] & 0x0f, + qdcount: msg.readUInt16BE(4), + ancount: msg.readUInt16BE(6), + nscount: msg.readUInt16BE(8), + arcount: msg.readUInt16BE(10), + }; +} + +// Send one DNS query to `port` on 127.0.0.1 and resolve with the raw response. +function askServer(port, query, { timeoutMs = 2000 } = {}) { + return new Promise((resolve, reject) => { + const sock = dgram.createSocket("udp4"); + const timer = setTimeout(() => { try { sock.close(); } catch {} reject(new Error("client timeout")); }, timeoutMs); + sock.on("message", (resp) => { clearTimeout(timer); try { sock.close(); } catch {} resolve(resp); }); + sock.on("error", (e) => { clearTimeout(timer); reject(e); }); + sock.send(query, port, "127.0.0.1"); + }); +} + +// Minimal DNS server that ALWAYS forwards its answer to `respFor(qname, qtype)` +// — used as a mock UPSTREAM. Returns { port, close }. +function startMockUpstream(respFor) { + const sock = dgram.createSocket("udp4"); + sock.on("message", (msg, rinfo) => { + const q = parseQuestion(msg); + if (!q) return; + // Echo the query back with an A record answer + preserve OPT if present. + const opt = extractOpt(msg, q.qend); + const answers = respFor(q.name, q.qtype); + sock.send(buildReply(msg, q.qend, { answers, opt }), rinfo.port, rinfo.address); + }); + return new Promise((resolve) => { + sock.bind(0, "127.0.0.1", () => resolve({ port: sock.address().port, close: () => sock.close() })); + }); +} + +async function startServer({ lookup, upstreamPort = 53 } = {}) { + return new Promise((resolve) => { + const sock = startDnsServer({ + port: 0, + bind: "127.0.0.1", + upstream: `127.0.0.1:${upstreamPort}`, + lookup, + onListening: (addr) => resolve({ port: addr.port, close: () => sock.close() }), + }); + }); +} + +// --- parseQuestion / buildReply unit tests ----------------------------------- + +test("parseQuestion parses a plain A query", () => { + const q = buildQuery("hello.bch", 1); + const parsed = parseQuestion(q); + assert.deepEqual(parsed && { name: parsed.name, qtype: parsed.qtype, qclass: parsed.qclass }, { + name: "hello.bch", qtype: 1, qclass: 1, + }); +}); + +test("parseQuestion tolerates an EDNS OPT record following the question", () => { + const q = buildQuery("hello.bch", 1, { edns: true }); + const parsed = parseQuestion(q); + assert.equal(parsed && parsed.name, "hello.bch"); + const opt = extractOpt(q, parsed.qend); + assert.ok(opt, "OPT record should be found in the additional section"); + assert.equal(opt.readUInt16BE(1), 41, "OPT TYPE = 41"); +}); + +test("parseQuestion rejects a query with QDCOUNT != 1", () => { + const q = buildQuery("hello.bch", 1); + q.writeUInt16BE(0, 4); // QDCOUNT = 0 + assert.equal(parseQuestion(q), null); +}); + +test("parseQuestion rejects a truncated label", () => { + const q = Buffer.from([ + 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x05, 0x68, 0x65, // says label length 5 but only "he" follows + ]); + assert.equal(parseQuestion(q), null); +}); + +test("buildReply echoes the OPT record when the query had one", () => { + const q = buildQuery("hello.bch", 1, { edns: true }); + const { qend } = parseQuestion(q); + const resp = buildReply(q, qend, { answers: ["1.2.3.4"] }); + const h = parseHeader(resp); + assert.equal(h.qr, 1); + assert.equal(h.aa, 1); + assert.equal(h.rcode, 0); + assert.equal(h.qdcount, 1); + assert.equal(h.ancount, 1); + assert.equal(h.arcount, 1, "OPT record must be echoed — a strict client sees FORMERR otherwise"); +}); + +test("buildReply produces NODATA (empty answers) with echoed OPT for AAAA on a known-A name", () => { + const q = buildQuery("hello.bch", 28, { edns: true }); // AAAA + const { qend } = parseQuestion(q); + const resp = buildReply(q, qend, { answers: [] }); + const h = parseHeader(resp); + assert.equal(h.rcode, 0, "NODATA is NOERROR, not NXDOMAIN"); + assert.equal(h.ancount, 0); + assert.equal(h.arcount, 1, "OPT must be echoed on NODATA too"); +}); + +test("buildReply drops OPT when the query had none", () => { + const q = buildQuery("hello.bch", 1, { edns: false }); + const { qend } = parseQuestion(q); + const resp = buildReply(q, qend, { answers: ["1.2.3.4"] }); + const h = parseHeader(resp); + assert.equal(h.arcount, 0, "must not fabricate OPT — RFC 6891 §7"); +}); + +test("buildReply preserves the query's RD flag", () => { + const rdOn = buildQuery("hello.bch", 1, { rd: true }); + const rdOff = buildQuery("hello.bch", 1, { rd: false }); + const { qend: e1 } = parseQuestion(rdOn); + const { qend: e2 } = parseQuestion(rdOff); + assert.equal(parseHeader(buildReply(rdOn, e1, { answers: ["1.2.3.4"] })).rd, 1); + assert.equal(parseHeader(buildReply(rdOff, e2, { answers: ["1.2.3.4"] })).rd, 0); +}); + +// --- integration: dns.Resolver against a live in-process server -------------- + +test("dns.Resolver.resolve4 on a BCNR-known name returns the on-chain IP", async (t) => { + const upstream = await startMockUpstream(() => []); + const server = await startServer({ + lookup: (name) => name === "hello.bch" ? { exists: true, ip: "203.0.113.10" } : null, + upstreamPort: upstream.port, + }); + t.after(() => { server.close(); upstream.close(); }); + + const r = new Resolver(); + r.setServers([`127.0.0.1:${server.port}`]); + const addrs = await r.resolve4("hello.bch"); + assert.deepEqual(addrs, ["203.0.113.10"]); +}); + +test("dns.Resolver.resolve6 on a BCNR-known name returns NODATA (no throw)", async (t) => { + const upstream = await startMockUpstream(() => []); + const server = await startServer({ + lookup: (name) => name === "hello.bch" ? { exists: true, ip: "203.0.113.10" } : null, + upstreamPort: upstream.port, + }); + t.after(() => { server.close(); upstream.close(); }); + + const r = new Resolver(); + r.setServers([`127.0.0.1:${server.port}`]); + // c-ares maps NODATA to ENODATA. Assert on that rather than a resolve() value. + await assert.rejects(r.resolve6("hello.bch"), (e) => e.code === "ENODATA"); +}); + +test("dns.Resolver.resolve4 on a non-BCNR name is forwarded to the mock upstream", async (t) => { + const upstream = await startMockUpstream((name, qtype) => name === "example.com" && qtype === 1 ? ["198.51.100.5"] : []); + const server = await startServer({ + lookup: () => null, // nothing on BCNR + upstreamPort: upstream.port, + }); + t.after(() => { server.close(); upstream.close(); }); + + const r = new Resolver(); + r.setServers([`127.0.0.1:${server.port}`]); + const addrs = await r.resolve4("example.com"); + assert.deepEqual(addrs, ["198.51.100.5"]); +}); + +// --- direct byte-level check: send EDNS query, confirm OPT survives ---------- + +test("EDNS0 OPT survives the round trip for a BCNR-known name", async (t) => { + const upstream = await startMockUpstream(() => []); + const server = await startServer({ + lookup: (name) => name === "hello.bch" ? { exists: true, ip: "1.2.3.4" } : null, + upstreamPort: upstream.port, + }); + t.after(() => { server.close(); upstream.close(); }); + + const q = buildQuery("hello.bch", 1, { edns: true, udpPayloadSize: 4096 }); + const resp = await askServer(server.port, q); + const h = parseHeader(resp); + assert.equal(h.qr, 1); + assert.equal(h.rcode, 0); + assert.equal(h.ancount, 1); + assert.equal(h.arcount, 1, "server dropped the OPT record — a strict resolver would fail"); +}); + +// --- regression tests for the OLD inline implementation in bnsd.js ----------- +// +// We copy the SHIPPED functions verbatim (as of git bf15d86) and prove they +// exhibit the failure the memory note describes. If these two tests pass, the +// bug is confirmed. If they fail (because someone already patched bnsd), the +// fix is already in place and the shipped daemon behaves like the new module. + +const TTL = 60; +function oldParseQuestion(msg) { + let i = 12; + const labels = []; + while (i < msg.length) { + const len = msg[i]; + if (len === 0) { i += 1; break; } + if (len > 63) return null; + labels.push(msg.subarray(i + 1, i + 1 + len).toString("ascii")); + i += 1 + len; + } + if (i + 4 > msg.length) return null; + const qtype = msg.readUInt16BE(i); + return { name: labels.join(".").toLowerCase(), qtype, qend: i + 4 }; +} +function oldReply(query, qend, { rcode = 0, answers = [] }) { + const question = query.subarray(12, qend); + const header = Buffer.alloc(12); + query.copy(header, 0, 0, 2); + header.writeUInt16BE(0x8580 | rcode, 2); + header.writeUInt16BE(1, 4); + header.writeUInt16BE(answers.length, 6); + const parts = [header, question]; + for (const ip of answers) { + const rr = Buffer.alloc(16); + rr.writeUInt16BE(0xc00c, 0); + rr.writeUInt16BE(1, 2); + rr.writeUInt16BE(1, 4); + rr.writeUInt32BE(TTL, 6); + rr.writeUInt16BE(4, 10); + const o = ip.split(".").map(Number); + for (let j = 0; j < 4; j++) rr[12 + j] = o[j]; + parts.push(rr); + } + return Buffer.concat(parts); +} + +test("OLD reply() drops the OPT record when the query had one — this is the shipped bug", () => { + const q = buildQuery("hello.bch", 1, { edns: true }); + const { qend } = oldParseQuestion(q); + const resp = oldReply(q, qend, { answers: ["1.2.3.4"] }); + const h = parseHeader(resp); + assert.equal(h.arcount, 0, "OLD reply drops the OPT — strict clients see FORMERR"); +}); + +test("OLD reply() for NODATA (AAAA on known A) also drops OPT", () => { + const q = buildQuery("hello.bch", 28, { edns: true }); + const { qend } = oldParseQuestion(q); + const resp = oldReply(q, qend, { answers: [] }); + const h = parseHeader(resp); + assert.equal(h.arcount, 0); +});