Skip to content

Commit 648fc58

Browse files
ralyodioclaude
andauthored
fix(dns): actually use the catch-all routing that #195 built (#200)
#195 added the catch-all config generators, upstream parsing and the forwarding path, and wired none of them in. `dns enable` still called resolvedConf(tlds) and `dns start` never passed upstreams, so v0.16.0 shipped the capability and none of the behaviour: a box that upgraded got the same 4586-ending list, the same silent truncation at the resolver's cap, and the same `curl: (6) Could not resolve host`. The release notes said routing had stopped being a list. It had not. Wiring it is the easy half. The hard half is that catch-all routing is only safe when the bridge can forward what is not ours — point every lookup at a bridge with no upstreams and the machine loses DNS entirely, which is far worse than a Moshpit name that does not resolve. So it is conditional by construction rather than by flag: - `discoverUpstreams` reads /etc/resolv.conf BEFORE routing is switched, because afterwards resolv.conf may point at us and the real servers are no longer discoverable from it - loopback entries are dropped, so the bridge cannot forward to itself - upstreams found → `Domains=~.` and the bridge forwards - none found → the per-ending list, exactly as before, which can only ever break Moshpit names - `dns start` passes the same upstreams and the claimed-ending set to the server, and says which upstreams it will use The dnsmasq backend follows the same rule, with no-resolv so it does not inherit upstreams that point back here. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8d534a5 commit 648fc58

3 files changed

Lines changed: 102 additions & 12 deletions

File tree

src/dns-system.mjs

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,14 @@ export function enablePlan({
5050
host = "127.0.0.1",
5151
port = 5354,
5252
linuxBackend = "systemd-resolved",
53+
// Catch-all routing is opt-in and conditional, never assumed. Sending every
54+
// lookup on the machine to the bridge is only safe if the bridge can forward
55+
// the ones that are not ours — so the caller passes the upstreams it found,
56+
// and an empty list keeps the per-ending routing that cannot break anything
57+
// beyond Moshpit names. Getting this backwards takes the whole box offline.
58+
upstreams = [],
5359
}) {
60+
const catchAll = Array.isArray(upstreams) && upstreams.length > 0;
5461
const clean = [...new Set((tlds || []).map((t) => String(t).replace(/^\.+/, "").toLowerCase()).filter(Boolean))];
5562
if (!clean.length) throw new Error("no TLDs to route");
5663

@@ -80,8 +87,16 @@ export function enablePlan({
8087
steps: [
8188
write(
8289
"/etc/dnsmasq.d/moshpit.conf",
83-
["# Written by `moshcode dns enable`.", ...clean.map((t) => `server=/${t}/${host}#${port}`), ""].join("\n"),
84-
"route the Moshpit TLDs",
90+
catchAll
91+
? [
92+
"# Written by `moshcode dns enable`.",
93+
"# no-resolv so dnsmasq does not also inherit upstreams that point back here.",
94+
"no-resolv",
95+
`server=${host}#${port}`,
96+
"",
97+
].join("\n")
98+
: ["# Written by `moshcode dns enable`.", ...clean.map((t) => `server=/${t}/${host}#${port}`), ""].join("\n"),
99+
catchAll ? "send every lookup to the bridge, which forwards what is not ours" : "route the Moshpit TLDs",
85100
),
86101
run("systemctl", ["restart", "dnsmasq"], "dnsmasq reads its config at start"),
87102
],
@@ -99,15 +114,31 @@ export function enablePlan({
99114
steps: [
100115
write(
101116
"/etc/systemd/resolved.conf.d/moshpit.conf",
102-
[
103-
"# Written by `moshcode dns enable`. Routes Moshpit TLDs to the local",
104-
"# bridge; every other name keeps using your normal resolver.",
105-
"[Resolve]",
106-
`DNS=${host}:${port}`,
107-
`Domains=${clean.map((t) => `~${t}`).join(" ")}`,
108-
"",
109-
].join("\n"),
110-
"route the Moshpit TLDs, and nothing else",
117+
catchAll
118+
? [
119+
"# Written by `moshcode dns enable`. Sends every lookup to the local",
120+
"# bridge, which answers claimed Moshpit endings and forwards the rest",
121+
"# upstream untouched.",
122+
"#",
123+
"# Naming each ending instead does not survive the registry growing:",
124+
"# systemd-resolved caps how many search domains it accepts and drops",
125+
"# the remainder with no error a caller can see.",
126+
"[Resolve]",
127+
`DNS=${host}:${port}`,
128+
"Domains=~.",
129+
"",
130+
].join("\n")
131+
: [
132+
"# Written by `moshcode dns enable`. Routes Moshpit TLDs to the local",
133+
"# bridge; every other name keeps using your normal resolver.",
134+
"[Resolve]",
135+
`DNS=${host}:${port}`,
136+
`Domains=${clean.map((t) => `~${t}`).join(" ")}`,
137+
"",
138+
].join("\n"),
139+
catchAll
140+
? "send every lookup to the bridge, which forwards what is not ours"
141+
: "route the Moshpit TLDs, and nothing else",
111142
),
112143
run("systemctl", ["restart", "systemd-resolved"], "drop-ins are read at start"),
113144
],

src/dns.mjs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,22 @@ export function createServer(options = {}) {
500500

501501
/* ------------------------------------------------------- system integration */
502502

503+
/**
504+
* The upstreams this machine was using before we touched anything.
505+
*
506+
* Read once, before routing is switched, because afterwards resolv.conf may
507+
* point at us and the real servers are no longer discoverable from it. An
508+
* empty result is the signal to leave routing per-ending: catch-all with
509+
* nowhere to forward is every lookup on the box failing, not just Moshpit ones.
510+
*/
511+
export async function discoverUpstreams(readImpl) {
512+
const read = readImpl || (async () => {
513+
const { readFile } = await import("node:fs/promises");
514+
return readFile("/etc/resolv.conf", "utf8");
515+
});
516+
return parseUpstreams(await read().catch(() => ""));
517+
}
518+
503519
/**
504520
* The routing suffixes the resolver actually accepted.
505521
*
@@ -792,10 +808,19 @@ export async function dnsCommand(args = [], out = console.log) {
792808
// parking host, which is all there ever was.
793809
const park = parking ? parking.address : await parkingAddress();
794810
if (!park) out("! parking host did not resolve — unpointed names will return NXDOMAIN");
811+
// Without these the bridge answers only for endings it is authoritative
812+
// for, which is correct for per-ending routing and fatal for catch-all.
813+
const upstreams = await discoverUpstreams();
814+
const tldSet = new Set(await fetchTlds({ registryBase }).catch(() => []));
815+
if (upstreams.length) out(`forwarding non-Moshpit lookups to ${upstreams.join(", ")}`);
816+
else out("! no upstreams found in /etc/resolv.conf — this bridge can only answer Moshpit names");
817+
795818
const server = await createServer({
796819
port,
797820
registryBase,
798821
parkingAddress: park,
822+
upstreams,
823+
tldSet,
799824
onQuery: ({ name, address }) => out(` ${name}${address || "NXDOMAIN"}`),
800825
});
801826
if (parking) out(`parked names → http://${parking.address}:${parking.port}${registryBase}/n/<name>`);
@@ -857,7 +882,7 @@ export async function dnsCommand(args = [], out = console.log) {
857882
let plan;
858883
try {
859884
plan = sub === "enable"
860-
? enablePlan({ platform, tlds, port: wanted, linuxBackend })
885+
? enablePlan({ platform, tlds, port: wanted, linuxBackend, upstreams: await discoverUpstreams() })
861886
: disablePlan({ platform, tlds, linuxBackend });
862887
} catch (err) {
863888
out(err.message);

test/dns-catchall.test.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,37 @@ test("the shortfall reproduces the failure that started this", async () => {
243243
assert.equal(shortfall.missing.length, 3496);
244244
assert.equal(shortfall.missing[0], "t1090");
245245
});
246+
247+
/* -------------------------------------- catch-all only when it is safe */
248+
249+
test("catch-all routing is written only when there is somewhere to forward", async () => {
250+
const { enablePlan } = await import("../src/dns-system.mjs");
251+
const conf = (plan) => plan.steps.find((s) => s.path?.includes("moshpit.conf"))?.content ?? "";
252+
253+
// With upstreams: one line that never grows.
254+
const withUp = enablePlan({ platform: "linux", tlds: ["eggs", "hacker"], upstreams: ["67.207.67.3"] });
255+
assert.match(conf(withUp), /^Domains=~\.$/m);
256+
257+
// Without: the per-ending list, which cannot take the machine's DNS with it.
258+
// Getting this backwards sends every lookup to a bridge with nowhere to
259+
// forward, and the whole box loses DNS rather than just Moshpit names.
260+
const withoutUp = enablePlan({ platform: "linux", tlds: ["eggs", "hacker"], upstreams: [] });
261+
assert.match(conf(withoutUp), /^Domains=~eggs ~hacker$/m);
262+
assert.doesNotMatch(conf(withoutUp), /~\./);
263+
264+
// Same rule for dnsmasq.
265+
const dnsmasqOn = enablePlan({ platform: "linux", linuxBackend: "dnsmasq", tlds: ["eggs"], upstreams: ["1.1.1.1"] });
266+
assert.match(conf(dnsmasqOn), /^no-resolv$/m);
267+
const dnsmasqOff = enablePlan({ platform: "linux", linuxBackend: "dnsmasq", tlds: ["eggs"], upstreams: [] });
268+
assert.match(conf(dnsmasqOff), /^server=\/eggs\//m);
269+
assert.doesNotMatch(dnsmasqOff.steps.map((s) => s.content).join(""), /no-resolv/);
270+
});
271+
272+
test("upstreams are read before routing is switched, and loopback is dropped", async () => {
273+
const { discoverUpstreams } = await import("../src/dns.mjs");
274+
const resolv = "nameserver 127.0.0.53\nnameserver 67.207.67.3\nnameserver 67.207.67.2\n";
275+
assert.deepEqual(await discoverUpstreams(async () => resolv), ["67.207.67.3", "67.207.67.2"]);
276+
// An unreadable resolv.conf must read as "no upstreams", which keeps routing
277+
// per-ending rather than pointing everything at a bridge that cannot forward.
278+
assert.deepEqual(await discoverUpstreams(async () => { throw new Error("nope"); }), []);
279+
});

0 commit comments

Comments
 (0)