diff --git a/README.md b/README.md index 76ffc53..fa18767 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,19 @@ The Moshpit registry speaks HTTP, not DNS. `pit.moshcode.sh` answers redirects tabs works, but redirecting is not resolving, and nothing outside that browser benefits from it. -This is the bridge: a tiny authoritative resolver that answers A queries for -Moshpit endings out of the registry's HTTP API, and is deliberately silent about -everything else. +This is the bridge: a tiny authoritative resolver that answers Moshpit endings +out of the registry's HTTP API, and is deliberately silent about everything +else. + +It answers `AAAA` and `A` from the name's target, and `CNAME`, `MX` and `TXT` +from the records its owner publishes in the Pit. Question types it does not +serve get an empty `NOERROR` rather than `NXDOMAIN` — the name exists, there is +just nothing to say about that question, and denying the name outright would +take its address down with it. + +A name pointed at a hostname rather than an address answers with the `CNAME` it +published, for the client to chase through its own resolver. This bridge never +does clearnet DNS itself. ## One suffix, not your whole resolver diff --git a/lib/dns.mjs b/lib/dns.mjs index e26b931..d08dd3a 100644 --- a/lib/dns.mjs +++ b/lib/dns.mjs @@ -29,10 +29,38 @@ export const DEFAULT_TIMEOUT_MS = 4000; export const TYPE_A = 1; export const TYPE_AAAA = 28; +export const TYPE_CNAME = 5; +export const TYPE_MX = 15; +export const TYPE_TXT = 16; const CLASS_IN = 1; const RCODE_OK = 0; const RCODE_NXDOMAIN = 3; +/** + * The question types answered out of the registry's record set, mapped to the + * name the registry calls them. + * + * Address questions are not in here. They are answered from `target`, which the + * registry keeps in step with the address records and which this bridge has + * read since before records existed — routing them through here would change + * how a name already resolving today gets its answer, to arrive at the same + * address. + */ +export const RECORD_TYPES = new Map([ + [TYPE_CNAME, "CNAME"], + [TYPE_MX, "MX"], + [TYPE_TXT, "TXT"], +]); + +/** + * What fits in a UDP answer without EDNS. + * + * 512 bytes is the floor every resolver accepts. Beyond it a datagram may be + * dropped by a middlebox rather than delivered short, so the reply is trimmed + * to what fits and marked truncated instead of being sent oversized and lost. + */ +export const UDP_SAFE_BYTES = 512; + /* ---------------------------------------------------------------- wire codec */ /** Encode a hostname as DNS labels. */ @@ -141,6 +169,108 @@ function ipv6(address) { return buf; } + +/** + * TXT rdata: one or more length-prefixed strings. + * + * Split at 255 bytes because that is the largest a single DNS character-string + * can be, and long TXT values are normal rather than exceptional — a DKIM key + * does not fit in one and is always carried as several. A client joins them + * back together, so the split is invisible above the wire. + * + * Split on bytes, not characters: a multi-byte character straddling the + * boundary would be cut in half and neither piece would decode. + */ +export function rdataTxt(value) { + const bytes = Buffer.from(String(value), "utf8"); + if (!bytes.length) return Buffer.from([0]); + const chunks = []; + for (let i = 0; i < bytes.length; i += 255) { + const chunk = bytes.subarray(i, i + 255); + chunks.push(Buffer.concat([Buffer.from([chunk.length]), chunk])); + } + return Buffer.concat(chunks); +} + +/** MX rdata: a 16-bit preference, then the exchange as labels. */ +export function rdataMx(priority, value) { + const preference = Buffer.alloc(2); + preference.writeUInt16BE(Math.min(65_535, Math.max(0, Number(priority) || 0)), 0); + return Buffer.concat([preference, encodeName(value)]); +} + +/** + * The rdata for one record from the registry, or null when it cannot be + * encoded. + * + * Null rather than a throw: one malformed record must not take down the answer + * for the ones beside it that are fine. The registry validates on the way in, + * so this is the second line — it is reading data over HTTP from a service that + * may be a different version than this bridge. + */ +export function encodeRdata(record) { + try { + if (record?.type === "TXT") return rdataTxt(record.value); + if (record?.type === "MX") return rdataMx(record.priority, record.value); + if (record?.type === "CNAME") return encodeName(record.value); + if (record?.type === "AAAA") return ipv6(record.value); + if (record?.type === "A") return ipv4(record.value); + } catch { + return null; + } + return null; +} + +const TYPE_NUMBERS = new Map([["A", TYPE_A], ["CNAME", TYPE_CNAME], ["MX", TYPE_MX], + ["TXT", TYPE_TXT], ["AAAA", TYPE_AAAA]]); + +/** + * A response carrying whole records rather than a bare address. + * + * Answers are fitted to `limit` and TC is set only if something was left out. A + * name with nine MX records should hand back the seven that fit and say it was + * truncated, not nothing at all — this bridge speaks UDP only, so a client that + * retries over TCP finds no one listening. + * + * `exists` carries the same NODATA/NXDOMAIN distinction buildResponse draws: a + * name with no TXT record still exists, and answering NXDOMAIN would deny it + * for every other type at once. + */ +export function buildRecordResponse(query, buf, records = [], { ttl = DEFAULT_TTL, exists = true, limit = UDP_SAFE_BYTES } = {}) { + const question = buf.subarray(12, query.questionEnd); + const encoded = []; + let dropped = false; + let size = 12 + question.length; + + for (const record of records) { + const rdata = encodeRdata(record); + const type = TYPE_NUMBERS.get(record?.type); + if (!rdata || !type) continue; + const answer = Buffer.alloc(12); + answer.writeUInt16BE(0xc00c, 0); // the question's name, by pointer + answer.writeUInt16BE(type, 2); + answer.writeUInt16BE(CLASS_IN, 4); + // The record's own TTL when it has one. An owner who set 60 on an address + // that moves meant it, and overriding it with the bridge's default would + // quietly hold the old answer for longer than they asked. + answer.writeUInt32BE(Number.isFinite(record.ttl) ? Math.max(0, Math.floor(record.ttl)) : ttl, 6); + answer.writeUInt16BE(rdata.length, 10); + + if (size + answer.length + rdata.length > limit) { dropped = true; continue; } + size += answer.length + rdata.length; + encoded.push(answer, rdata); + } + + const answers = encoded.length / 2; + const head = header(query.id, { + rcode: answers || exists ? RCODE_OK : RCODE_NXDOMAIN, + answers, + recursionDesired: query.recursionDesired, + }); + if (dropped) head.writeUInt16BE(head.readUInt16BE(2) | 0x0200, 2); // TC + return Buffer.concat([head, question, ...encoded]); +} + /** * Build an address-record response for the family the query asked for. * @@ -248,28 +378,63 @@ export async function fetchTlds({ */ export async function resolveName( name, - { registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}, + { registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, records = false } = {}, ) { const parsed = parseRegistryName(name); if (!parsed) return { status: "not-a-name", target: null }; try { + // `&records=1` only when the question needs the whole set. Every address + // lookup on the machine comes through here, and the registry does a second + // query to answer it — a browser opening a page must not pay for records it + // will never read. const url = `${registryBase.replace(/\/+$/, "")}/api/moshpit/resolve?name=${encodeURIComponent( `${parsed.label}.${parsed.tld}`, - )}`; + )}${records ? "&records=1" : ""}`; const { res, json } = await fetchJsonWithTimeout(fetchImpl, url, timeoutMs); if (!res.ok) return { status: "unreachable", target: null }; const claimed = typeof json?.name_registered === "boolean" ? json.name_registered : json?.registered; if (typeof claimed !== "boolean") return { status: "unreachable", target: null }; + // The `records` key appears only when it was asked for. Every caller that + // wants an address deep-compares this shape, and an empty array they never + // requested is a difference they would have to be taught to ignore. + const found = records ? { records: Array.isArray(json.records) ? json.records : [] } : {}; const target = typeof json.target === "string" && json.target ? json.target : null; - if (target) return { status: "live", target }; - return { status: "parked", target: null, registered: claimed }; + if (target) return { status: "live", target, ...found }; + return { status: "parked", target: null, registered: claimed, ...found }; } catch { return { status: "unreachable", target: null }; } } +/** + * The records of one type a name publishes, and whether the name is here. + * + * Both halves matter and they are not the same question: a name with no MX + * record still exists, so the answer is NODATA, while a name nobody holds is + * NXDOMAIN. Collapsing them would let a missing MX deny the name's address too. + */ +export async function answerRecords(name, options = {}) { + const { type } = options; + const result = await resolveName(name, { ...options, records: true }); + const exists = result.status === "live" || result.status === "parked"; + if (!exists || !type) return { exists, records: [] }; + return { exists, records: (result.records || []).filter((r) => r?.type === type) }; +} + +/** + * Is an address question on this name worth a second look for a CNAME? + * + * True when the name is here and has no address to give. A CNAME is the one + * thing that can still answer such a question, and finding out costs another + * round trip to the registry — so it is asked only on the path that would + * otherwise return nothing at all, never on a name that already has an address. + */ +export function mayHaveCname({ exists, address }) { + return Boolean(exists) && !address; +} + /* -------------------------------------------------------------------- server */ /** @@ -350,18 +515,37 @@ export function createServer(options = {}) { if (!query) return; // malformed, or a response — say nothing at all let address = 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, + let reply = null; + + // Three shapes of question. CNAME, MX and TXT are answered from the record + // set; addresses are answered from `target` as they always have been; + // everything else (HTTPS/SVCB and the rest) still gets an honest empty + // NOERROR rather than a lie — a browser asks HTTPS beside every A and AAAA, // and NXDOMAIN to that one denies the name for the whole page load. - if (query.class === CLASS_IN) { + const wanted = query.class === CLASS_IN ? RECORD_TYPES.get(query.type) : null; + if (wanted) { + const found = await answerRecords(query.name, { ...options, type: wanted }).catch(() => null); + exists = Boolean(found?.exists); + 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 }); + } + } } onQuery({ name: query.name, type: query.type, address }); try { - socket.send(buildResponse(query, msg, address, ttl, exists), rinfo.port, rinfo.address); + socket.send(reply || buildResponse(query, msg, address, ttl, exists), rinfo.port, rinfo.address); } catch { /* client vanished — nothing useful to do */ } diff --git a/package.json b/package.json index 3026159..913a020 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@moshcoder/moshpit-dns", - "version": "0.2.1", + "version": "0.3.0", "type": "module", "license": "MIT", "description": "Resolve Moshpit names on this machine. A tiny DNS bridge for .whatever endings, routed per-suffix so the rest of the internet is untouched.", diff --git a/test/dns-records.test.mjs b/test/dns-records.test.mjs new file mode 100644 index 0000000..481a0ae --- /dev/null +++ b/test/dns-records.test.mjs @@ -0,0 +1,295 @@ +/** + * The record types the bridge answers beyond an address. + * + * A name could publish an MX and a TXT in the registry while `dig MX` came + * back empty, because the bridge gated on A/AAAA and returned NODATA for + * everything else. The records existed and nothing served them. + * + * So these tests read the rdata back off the wire rather than trusting an + * answer count: a TXT record split at the wrong byte, or an MX with its + * preference in the wrong order, produces a reply of exactly the right shape + * that no client can use. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import dgram from "node:dgram"; + +import { + answerRecords, buildRecordResponse, createServer, encodeName, parseQuery, + rdataMx, rdataTxt, TYPE_AAAA, TYPE_CNAME, TYPE_MX, TYPE_TXT, +} from "../lib/dns.mjs"; + +const TYPE_A = 1; +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 count = (reply) => reply.readUInt16BE(6); +const truncated = (reply) => Boolean(reply.readUInt16BE(2) & 0x0200); + +/** Labels out of an rdata buffer — the encoder never writes a pointer. */ +function readName(buf, offset = 0) { + const labels = []; + let i = offset; + for (;;) { + const len = buf[i]; + if (len === undefined) throw new Error("truncated"); + if (len === 0) return { name: labels.join("."), offset: i + 1 }; + i += 1; + labels.push(buf.toString("ascii", i, i + len)); + i += len; + } +} + +/** Every answer in a reply, decoded far enough to be checked. */ +function readAnswers(reply, name) { + const questionEnd = 12 + encodeName(name).length + 4; + const found = []; + let i = questionEnd; + for (let n = 0; n < count(reply); n++) { + assert.equal(reply.readUInt16BE(i), 0xc00c, "answer does not point at the question's name"); + const type = reply.readUInt16BE(i + 2); + const ttl = reply.readUInt32BE(i + 6); + const length = reply.readUInt16BE(i + 10); + const rdata = reply.subarray(i + 12, i + 12 + length); + const record = { type, ttl }; + if (type === TYPE_TXT) { + // Rejoined the way a client does: the 255-byte split is invisible above + // the wire, so a value that survives the round trip must come back whole. + const parts = []; + let at = 0; + while (at < rdata.length) { + const len = rdata[at]; + parts.push(rdata.toString("utf8", at + 1, at + 1 + len)); + at += 1 + len; + } + record.value = parts.join(""); + } else if (type === TYPE_MX) { + record.priority = rdata.readUInt16BE(0); + record.value = readName(rdata, 2).name; + } else if (type === TYPE_CNAME) { + record.value = readName(rdata).name; + } else { + record.value = rdata; + } + found.push(record); + i += 12 + length; + } + return found; +} + +/** A registry answering /api/moshpit/resolve, with or without `&records=1`. */ +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; +} + +/* ------------------------------------------------------------------ encoders */ + +test("TXT longer than 255 bytes is split into strings a client rejoins", () => { + const value = "k".repeat(600); + const rdata = rdataTxt(value); + // 600 bytes as 255 + 255 + 90, each with its own length byte. + assert.equal(rdata.length, 600 + 3); + assert.equal(rdata[0], 255); + assert.equal(rdata[256], 255); + assert.equal(rdata[512], 90); +}); + +test("TXT splits on bytes, so a multi-byte character is not cut in half", () => { + // 200 three-byte characters: the 255-byte boundary lands mid-character if the + // split counts characters instead of bytes, and neither half then decodes. + const value = "🤘".repeat(60); + const rdata = rdataTxt(value); + const parts = []; + let at = 0; + while (at < rdata.length) { + const len = rdata[at]; + parts.push(rdata.subarray(at + 1, at + 1 + len)); + at += 1 + len; + } + assert.equal(Buffer.concat(parts).toString("utf8"), value, "the value did not survive the split"); +}); + +test("MX puts the preference first, as the wire format requires", () => { + const rdata = rdataMx(20, "mx.example.com"); + assert.equal(rdata.readUInt16BE(0), 20); + assert.equal(readName(rdata, 2).name, "mx.example.com"); +}); + +test("a record with rdata that cannot be encoded is dropped, not fatal", () => { + const buf = query("blue.eggs", { type: TYPE_MX }); + const parsed = parseQuery(buf); + const reply = buildRecordResponse(buf && parsed, buf, [ + { type: "MX", value: "mx.example.com", ttl: 300, priority: 10 }, + { type: "AAAA", value: "not-an-address", ttl: 300, priority: null }, + ]); + // The good one still answers. One bad row from a registry running a different + // version must not take the reply down with it. + assert.equal(count(reply), 1); + assert.equal(readAnswers(reply, "blue.eggs")[0].value, "mx.example.com"); +}); + +/* -------------------------------------------------------------- over the wire */ + +test("an MX question is answered from the record set, preference and all", async (t) => { + const server = await serve(t, registry({ + target: "2606:4700:4700::1111", + records: [ + { type: "MX", value: "mx1.example.com", ttl: 300, priority: 10 }, + { type: "MX", value: "mx2.example.com", ttl: 300, priority: 20 }, + ], + })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_MX })); + + assert.equal(rcode(reply), RCODE_OK); + assert.equal(count(reply), 2); + assert.deepEqual(readAnswers(reply, "blue.eggs").map((r) => [r.priority, r.value]), [ + [10, "mx1.example.com"], + [20, "mx2.example.com"], + ]); +}); + +test("a TXT question comes back with the value intact", async (t) => { + const value = "v=spf1 include:example.com -all"; + const server = await serve(t, registry({ records: [{ type: "TXT", value, ttl: 60, priority: null }] })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_TXT })); + + const [answer] = readAnswers(reply, "blue.eggs"); + assert.equal(answer.value, value); + // The owner's TTL, not the bridge's default: they set 60 on purpose. + assert.equal(answer.ttl, 60); +}); + +test("a name with no record of the type asked for is NODATA, not NXDOMAIN", async (t) => { + const server = await serve(t, registry({ records: [{ type: "TXT", value: "hi", ttl: 300, priority: null }] })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_MX })); + + // The name is here — denying it would deny its address too. + assert.equal(rcode(reply), RCODE_OK); + assert.equal(count(reply), 0); +}); + +test("an unregistered name under a claimed ending is parked, not denied", async (t) => { + // The bridge's existing verdict for a name with no target, and record + // questions have to agree with it: parking is a name waiting to be pointed, + // and NXDOMAIN on the TXT question would deny the parking address the A + // question is about to hand back. + const server = await serve(t, registry({ target: null, records: [] })); + const reply = await ask(server, query("nobody.eggs", { type: TYPE_TXT })); + assert.equal(rcode(reply), RCODE_OK); + assert.equal(count(reply), 0); +}); + +test("a record question about something that is not a name at all is NXDOMAIN", async (t) => { + // Three labels cannot be a Moshpit name — the namespace is one level deep — + // so there is nothing here to be waiting to be pointed. + const server = await serve(t, registry({ records: [] })); + const reply = await ask(server, query("not.a.name", { type: TYPE_TXT })); + assert.equal(rcode(reply), RCODE_NXDOMAIN); +}); + +test("a published CNAME answers the address question that had nothing to say", async (t) => { + // The name's target is a hostname, so there is no address to hand back — + // which used to be NODATA and looked identical to a typo. + const server = await serve(t, registry({ + target: "box.example.com", + records: [{ type: "CNAME", value: "box.example.com", ttl: 300, priority: null }], + })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_A })); + + assert.equal(rcode(reply), RCODE_OK); + assert.equal(count(reply), 1); + const [answer] = readAnswers(reply, "blue.eggs"); + assert.equal(answer.type, TYPE_CNAME); + assert.equal(answer.value, "box.example.com"); +}); + +test("a name with an address never pays for the CNAME lookup", async (t) => { + const reg = registry({ target: "2606:4700:4700::1111" }); + const server = await serve(t, reg); + await ask(server, query("blue.eggs", { type: TYPE_AAAA })); + + assert.equal(reg.calls.length, 1, "an address question made a second round trip"); + assert.equal(reg.calls.filter((u) => u.includes("records=1")).length, 0, + "an address question asked the registry for records it would not read"); +}); + +test("an oversized answer is trimmed and marked truncated, not sent whole", async (t) => { + // Twenty MX records is far past what fits in a 512-byte datagram. + const records = Array.from({ length: 20 }, (_, i) => ({ + type: "MX", value: `mx${i}.averyveryverylongmailhostname.example.com`, ttl: 300, priority: i, + })); + const server = await serve(t, registry({ records })); + const reply = await ask(server, query("blue.eggs", { type: TYPE_MX })); + + assert.ok(reply.length <= 512, `reply was ${reply.length} bytes`); + assert.ok(count(reply) > 0, "trimming dropped every answer instead of what did not fit"); + assert.ok(count(reply) < 20, "nothing was actually dropped, so this proves nothing"); + assert.ok(truncated(reply), "TC was not set, so the client cannot tell it got a partial answer"); +}); + +/* ---------------------------------------------------------------------- policy */ + +test("answerRecords separates 'no such record' from 'no such name'", async () => { + // Here, with a TXT but no MX: NODATA, which must not deny the name. + const here = registry({ records: [{ type: "TXT", value: "hi", ttl: 300, priority: null }] }); + assert.deepEqual(await answerRecords("blue.eggs", { fetchImpl: here.fetchImpl, type: "MX" }), + { exists: true, records: [] }); + + // Not a name this registry can be asked about at all. + const reg = registry({ records: [] }); + assert.deepEqual(await answerRecords("not.a.name", { fetchImpl: reg.fetchImpl, type: "MX" }), + { exists: false, records: [] }); + assert.equal(reg.calls.length, 0, "a name it could reject on sight still cost a round trip"); +}); + +test("answerRecords asks the registry for the record set exactly once", async () => { + const reg = registry({ records: [] }); + await answerRecords("blue.eggs", { fetchImpl: reg.fetchImpl, type: "TXT" }); + assert.equal(reg.calls.length, 1); + assert.match(reg.calls[0], /records=1/); +}); +