Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions src/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const TYPE_A = 1;
export const TYPE_AAAA = 28;
const CLASS_IN = 1;
const RCODE_OK = 0;
const RCODE_SERVFAIL = 2;
const RCODE_NXDOMAIN = 3;

/* ---------------------------------------------------------------- wire codec */
Expand Down Expand Up @@ -358,18 +359,107 @@ export function targetAddress(target) {
* `parkingAddress` is resolved once by the caller (an A record must carry an
* IP, not a name) and passed in, so the server itself never does clearnet DNS.
*/
/**
* Send a query to an upstream nameserver and hand back its answer verbatim.
*
* Deliberately a byte proxy rather than a parse-and-rebuild. We forward
* question types this bridge has no opinion about — SVCB, SRV, DNSKEY,
* whatever arrives — and re-encoding them would mean implementing the whole
* record space correctly to avoid corrupting answers we only need to relay.
*/
export function forwardQuery(msg, upstream, { timeoutMs = 3000 } = {}) {
const [address, portText] = String(upstream).split("#");
const port = Number(portText) || 53;
return new Promise((resolve) => {
const socket = dgram.createSocket(isIP(address) === 6 ? "udp6" : "udp4");
let settled = false;
const finish = (reply) => {
if (settled) return;
settled = true;
try { socket.close(); } catch { /* already closing */ }
resolve(reply);
};
const timer = setTimeout(() => finish(null), timeoutMs);
timer.unref?.();
socket.on("message", (reply) => { clearTimeout(timer); finish(reply); });
socket.on("error", () => { clearTimeout(timer); finish(null); });
try {
socket.send(msg, port, address);
} catch {
clearTimeout(timer);
finish(null);
}
});
}

/**
* Is this a name we are authoritative for?
*
* The gate that makes catch-all routing safe. With `Domains=~.` every lookup
* on the machine arrives here, and `google.com` is two labels exactly like
* `blue.eggs` is — so parsing alone would have us answer for the clearnet.
* Only an ending someone has actually claimed is ours; everything else is
* forwarded untouched.
*
* An unknown ending set means "not ours" rather than "ours". Failing that way
* round costs a Moshpit name that does not resolve until the registry answers
* again; the other way round costs the whole internet on that machine.
*/
export function isOurs(name, tldSet) {
if (!(tldSet instanceof Set) || tldSet.size === 0) return false;
const parsed = parseRegistryName(name);
return Boolean(parsed) && tldSet.has(parsed.tld);
}

export function createServer(options = {}) {
const {
port = DEFAULT_PORT,
host = DEFAULT_HOST,
ttl = DEFAULT_TTL,
onQuery = () => {},
// Empty by default, which keeps the old behaviour exactly: with no
// upstreams there is nothing to forward to, so the bridge stays the
// narrow per-ending resolver it has always been and answers only for
// names it is authoritative for.
upstreams = [],
tldSet = null,
forwardTimeoutMs = 3000,
} = options;
const socket = dgram.createSocket("udp4");

socket.on("message", async (msg, rinfo) => {
const query = parseQuery(msg);
if (!query) return; // malformed, or a response — say nothing at all

// Catch-all routing puts every lookup on the machine through here. Anything
// that is not an ending someone has claimed belongs to the ordinary
// internet and is relayed byte for byte, including question types this
// bridge has no opinion about.
if (upstreams.length && !isOurs(query.name, tldSet)) {
let relayed = null;
for (const upstream of upstreams) {
relayed = await forwardQuery(msg, upstream, { timeoutMs: forwardTimeoutMs });
if (relayed) break;
}
onQuery({ name: query.name, type: query.type, address: null, forwarded: true });
try {
// SERVFAIL, not NXDOMAIN, when every upstream is silent: "I could not
// find out" is retried elsewhere, "it does not exist" gets cached and
// the name stays broken after the network comes back.
socket.send(
relayed || Buffer.concat([
header(query.id, { rcode: RCODE_SERVFAIL, answers: 0, recursionDesired: query.recursionDesired }),
msg.subarray(12, query.questionEnd),
]),
rinfo.port,
rinfo.address,
);
} catch {
/* client vanished */
}
return;
}

let address = null;
let exists = false;
// Only address questions can be answered with an address; everything else
Expand Down Expand Up @@ -410,6 +500,74 @@ export function createServer(options = {}) {
* `~tld` is a routing-only domain: it sends queries for that suffix here
* without making this resolver the default for anything else on the machine.
*/
/* ------------------------------------------------- catch-all routing */

/**
* Route every lookup here, instead of naming each claimed ending.
*
* The per-ending form does not scale and fails silently when it stops. Listing
* 4586 endings on one `Domains=` line made systemd-resolved take them
* alphabetically until it hit its own cap, reject the remaining 3496 with
* "Argument list too long" one line at a time in the journal, and report
* success. Names past the cut were configured on disk and absent from the
* resolver, so `moshcode dns resolve` answered and `curl` did not — with
* nothing in between to say why. Every new ending anyone claims makes that
* worse.
*
* `~.` is one entry that never grows. The cost is that this bridge now sees
* every lookup on the machine, so it has to be a resolver rather than an
* oracle: anything that is not a claimed Moshpit name is forwarded upstream
* untouched, and any failure forwards too. Breaking DNS for the whole box is a
* far worse outcome than failing to resolve a Moshpit name.
*/
export function resolvedCatchAllConf({ host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
return [
"# Written by `moshcode dns install`. Sends every lookup to the local",
"# bridge, which answers Moshpit endings and forwards the rest upstream.",
"#",
"# Routing each ending by name instead does not survive the registry",
"# growing: systemd-resolved caps how many search domains it accepts and",
"# drops the rest with no error a caller can see.",
"[Resolve]",
`DNS=${host}:${port}`,
"Domains=~.",
"",
].join("\n");
}

/** The dnsmasq equivalent: one upstream for everything. */
export function dnsmasqCatchAllConf({ host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
return [
"# Written by `moshcode dns install`.",
"# no-resolv so dnsmasq does not also inherit the upstreams from",
"# /etc/resolv.conf, which on a machine running this bridge may point back",
"# here and loop.",
"no-resolv",
`server=${host}#${port}`,
"",
].join("\n");
}

/**
* The machine's real nameservers, for the bridge to forward to.
*
* Loopback entries are dropped: once routing points at this bridge, whatever
* wrote 127.0.0.53 into resolv.conf is the thing sending us the query, and
* forwarding back to it is a loop that ends in a timeout rather than an answer.
*/
export function parseUpstreams(resolvConf) {
const out = [];
for (const line of String(resolvConf ?? "").split("\n")) {
const m = line.match(/^\s*nameserver\s+(\S+)/i);
if (!m) continue;
const address = m[1].replace(/%.*$/, "");
if (!isIP(address)) continue;
if (/^127\./.test(address) || address === "::1") continue;
if (!out.includes(address)) out.push(address);
}
return out;
}

export function resolvedConf(tlds, { host = DEFAULT_HOST, port = DEFAULT_PORT } = {}) {
return [
"# Written by `moshcode dns install`. Routes Moshpit TLDs to the local",
Expand Down
205 changes: 205 additions & 0 deletions test/dns-catchall.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Catch-all routing: the bridge as the machine's resolver rather than an oracle
// for a list of endings.
//
// Routing each claimed ending by name did not survive the registry growing —
// systemd-resolved caps how many search domains it accepts, took 1090 of 4586
// alphabetically, and rejected the rest one journal line at a time while
// reporting success. `~.` is one entry that never grows.
//
// The bill for that is this file. Every lookup on the machine now arrives here,
// so the tests that matter are the ones about what the bridge must NOT answer.
import test from "node:test";
import assert from "node:assert/strict";
import dgram from "node:dgram";

import {
createServer,
dnsmasqCatchAllConf,
encodeName,
forwardQuery,
isOurs,
parseUpstreams,
resolvedCatchAllConf,
TYPE_A,
} from "../src/dns.mjs";

function query(name, { id = 0x1234, type = TYPE_A } = {}) {
const head = Buffer.alloc(12);
head.writeUInt16BE(id, 0);
head.writeUInt16BE(0x0100, 2);
head.writeUInt16BE(1, 4);
const tail = Buffer.alloc(4);
tail.writeUInt16BE(type, 0);
tail.writeUInt16BE(1, 2);
return Buffer.concat([head, encodeName(name), tail]);
}

const okJson = (body) => async () => ({ ok: true, json: async () => body });
const rcode = (b) => b.readUInt16BE(2) & 0x000f;
const answers = (b) => b.readUInt16BE(6);

async function ask(server, name, type = TYPE_A) {
const client = dgram.createSocket("udp4");
try {
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("no reply")), 5000);
client.once("message", (m) => { clearTimeout(timer); resolve(m); });
client.send(query(name, { type }), server.port, "127.0.0.1");
});
} finally {
client.close();
}
}

/** A stand-in upstream that answers everything with one fixed A record. */
async function fakeUpstream(address = "203.0.113.55") {
const socket = dgram.createSocket("udp4");
socket.on("message", (msg, rinfo) => {
const head = Buffer.alloc(12);
head.writeUInt16BE(msg.readUInt16BE(0), 0);
head.writeUInt16BE(0x8180, 2);
head.writeUInt16BE(1, 4);
head.writeUInt16BE(1, 6);
let i = 12;
while (msg[i] !== 0) i += msg[i] + 1;
const question = msg.subarray(12, i + 5);
const answer = Buffer.alloc(12);
answer.writeUInt16BE(0xc00c, 0);
answer.writeUInt16BE(1, 2);
answer.writeUInt16BE(1, 4);
answer.writeUInt32BE(60, 6);
answer.writeUInt16BE(4, 10);
socket.send(
Buffer.concat([head, question, answer, Buffer.from(address.split(".").map(Number))]),
rinfo.port, rinfo.address,
);
});
await new Promise((r) => socket.bind(0, "127.0.0.1", r));
return { port: socket.address().port, close: () => new Promise((d) => socket.close(d)) };
}

/* ------------------------------------------------------------ the safety gate */

test("a clearnet name is never ours, however much it looks like a Moshpit one", () => {
// The whole risk of catch-all routing in one assertion: `google.com` has
// exactly two labels, same as `blue.eggs`. Parsing alone would have the
// bridge answer for the internet.
const claimed = new Set(["eggs", "oranges", "hacker"]);
for (const name of ["google.com", "example.org", "news.ycombinator.com", "a.io"]) {
assert.equal(isOurs(name, claimed), false, name);
}
assert.equal(isOurs("blue.eggs", claimed), true);
assert.equal(isOurs("chovy.hacker", claimed), true);
});

test("an unknown ending set means not ours, never ours", () => {
// Failing this way costs a Moshpit name until the registry answers again.
// Failing the other way costs the whole internet on that machine.
for (const set of [null, undefined, new Set(), "eggs", []]) {
assert.equal(isOurs("blue.eggs", set), false, String(set));
}
});

/* --------------------------------------------------------------- forwarding */

test("a clearnet lookup is relayed to the upstream and back", async (t) => {
const upstream = await fakeUpstream("203.0.113.55");
t.after(() => upstream.close());
const server = await createServer({
port: 0,
upstreams: [`127.0.0.1#${upstream.port}`],
tldSet: new Set(["eggs"]),
fetchImpl: okJson({ name_registered: true, target: "203.0.113.7" }),
});
t.after(() => server.close());

const reply = await ask(server, "google.com");
assert.equal(rcode(reply), 0);
assert.equal(answers(reply), 1);
assert.deepEqual([...reply.subarray(reply.length - 4)], [203, 0, 113, 55], "the upstream's answer");
});

test("a Moshpit name is answered here, not forwarded", async (t) => {
const upstream = await fakeUpstream("203.0.113.55");
t.after(() => upstream.close());
const server = await createServer({
port: 0,
upstreams: [`127.0.0.1#${upstream.port}`],
tldSet: new Set(["eggs"]),
fetchImpl: okJson({ name_registered: true, target: "203.0.113.7" }),
});
t.after(() => server.close());

const reply = await ask(server, "blue.eggs");
assert.deepEqual([...reply.subarray(reply.length - 4)], [203, 0, 113, 7], "ours, not the upstream's");
});

test("silent upstreams are SERVFAIL, never NXDOMAIN", async (t) => {
// "I could not find out" is retried elsewhere. "It does not exist" gets
// cached, and the name stays broken after the network comes back.
const server = await createServer({
port: 0,
upstreams: ["127.0.0.1#1"], // nothing listens there
tldSet: new Set(["eggs"]),
forwardTimeoutMs: 300,
fetchImpl: okJson({ name_registered: false, target: null }),
});
t.after(() => server.close());

const reply = await ask(server, "google.com");
assert.equal(rcode(reply), 2, "SERVFAIL");
assert.equal(answers(reply), 0);
});

test("with no upstreams configured the bridge behaves exactly as before", async (t) => {
// The per-ending deployment still works: nothing to forward to means answer
// only for what we are authoritative for.
const server = await createServer({
port: 0,
fetchImpl: okJson({ name_registered: true, target: "203.0.113.7" }),
});
t.after(() => server.close());

const reply = await ask(server, "blue.eggs");
assert.deepEqual([...reply.subarray(reply.length - 4)], [203, 0, 113, 7]);
});

test("forwardQuery gives up rather than hanging on a dead upstream", async () => {
const started = Date.now();
assert.equal(await forwardQuery(query("x.eggs"), "127.0.0.1#1", { timeoutMs: 200 }), null);
assert.ok(Date.now() - started < 4000, "returned promptly");
});

/* ------------------------------------------------------------------- config */

test("the resolver config is one line that never grows", () => {
const conf = resolvedCatchAllConf({ port: 5354 });
assert.match(conf, /^Domains=~\.$/m);
assert.match(conf, /^DNS=127\.0\.0\.1:5354$/m);
// The failure this replaces: 4586 endings on one line, of which the resolver
// silently kept 1090.
assert.ok(conf.length < 600, "no per-ending list to truncate");
});

test("the dnsmasq config does not inherit upstreams that point back here", () => {
const conf = dnsmasqCatchAllConf({ port: 5354 });
assert.match(conf, /^no-resolv$/m, "or dnsmasq loops through /etc/resolv.conf");
assert.match(conf, /^server=127\.0\.0\.1#5354$/m);
});

test("loopback nameservers are dropped when finding upstreams", () => {
// Once routing points here, 127.0.0.53 is the thing asking us — forwarding
// back to it is a loop that ends in a timeout instead of an answer.
const resolv = [
"# generated",
"nameserver 127.0.0.53",
"nameserver 67.207.67.3",
"nameserver 67.207.67.2",
"nameserver ::1",
"nameserver 2001:4860:4860::8888",
"options edns0",
].join("\n");
assert.deepEqual(parseUpstreams(resolv), ["67.207.67.3", "67.207.67.2", "2001:4860:4860::8888"]);
assert.deepEqual(parseUpstreams(""), []);
assert.deepEqual(parseUpstreams(null), []);
});
Loading