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

Expand Down Expand Up @@ -80,8 +87,16 @@ export function enablePlan({
steps: [
write(
"/etc/dnsmasq.d/moshpit.conf",
["# Written by `moshcode dns enable`.", ...clean.map((t) => `server=/${t}/${host}#${port}`), ""].join("\n"),
"route the Moshpit TLDs",
catchAll
? [
"# Written by `moshcode dns enable`.",
"# no-resolv so dnsmasq does not also inherit upstreams that point back here.",
"no-resolv",
`server=${host}#${port}`,
"",
].join("\n")
: ["# Written by `moshcode dns enable`.", ...clean.map((t) => `server=/${t}/${host}#${port}`), ""].join("\n"),
catchAll ? "send every lookup to the bridge, which forwards what is not ours" : "route the Moshpit TLDs",
),
run("systemctl", ["restart", "dnsmasq"], "dnsmasq reads its config at start"),
],
Expand All @@ -99,15 +114,31 @@ export function enablePlan({
steps: [
write(
"/etc/systemd/resolved.conf.d/moshpit.conf",
[
"# Written by `moshcode dns enable`. Routes Moshpit TLDs to the local",
"# bridge; every other name keeps using your normal resolver.",
"[Resolve]",
`DNS=${host}:${port}`,
`Domains=${clean.map((t) => `~${t}`).join(" ")}`,
"",
].join("\n"),
"route the Moshpit TLDs, and nothing else",
catchAll
? [
"# Written by `moshcode dns enable`. Sends every lookup to the local",
"# bridge, which answers claimed Moshpit endings and forwards the rest",
"# upstream untouched.",
"#",
"# Naming each ending instead does not survive the registry growing:",
"# systemd-resolved caps how many search domains it accepts and drops",
"# the remainder with no error a caller can see.",
"[Resolve]",
`DNS=${host}:${port}`,
"Domains=~.",
"",
].join("\n")
: [
"# Written by `moshcode dns enable`. Routes Moshpit TLDs to the local",
"# bridge; every other name keeps using your normal resolver.",
"[Resolve]",
`DNS=${host}:${port}`,
`Domains=${clean.map((t) => `~${t}`).join(" ")}`,
"",
].join("\n"),
catchAll
? "send every lookup to the bridge, which forwards what is not ours"
: "route the Moshpit TLDs, and nothing else",
),
run("systemctl", ["restart", "systemd-resolved"], "drop-ins are read at start"),
],
Expand Down
27 changes: 26 additions & 1 deletion src/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,22 @@ export function createServer(options = {}) {

/* ------------------------------------------------------- system integration */

/**
* The upstreams this machine was using before we touched anything.
*
* Read once, before routing is switched, because afterwards resolv.conf may
* point at us and the real servers are no longer discoverable from it. An
* empty result is the signal to leave routing per-ending: catch-all with
* nowhere to forward is every lookup on the box failing, not just Moshpit ones.
*/
export async function discoverUpstreams(readImpl) {
const read = readImpl || (async () => {
const { readFile } = await import("node:fs/promises");
return readFile("/etc/resolv.conf", "utf8");
});
return parseUpstreams(await read().catch(() => ""));
}

/**
* The routing suffixes the resolver actually accepted.
*
Expand Down Expand Up @@ -792,10 +808,19 @@ export async function dnsCommand(args = [], out = console.log) {
// parking host, which is all there ever was.
const park = parking ? parking.address : await parkingAddress();
if (!park) out("! parking host did not resolve — unpointed names will return NXDOMAIN");
// Without these the bridge answers only for endings it is authoritative
// for, which is correct for per-ending routing and fatal for catch-all.
const upstreams = await discoverUpstreams();
const tldSet = new Set(await fetchTlds({ registryBase }).catch(() => []));
if (upstreams.length) out(`forwarding non-Moshpit lookups to ${upstreams.join(", ")}`);
else out("! no upstreams found in /etc/resolv.conf — this bridge can only answer Moshpit names");

const server = await createServer({
port,
registryBase,
parkingAddress: park,
upstreams,
tldSet,
onQuery: ({ name, address }) => out(` ${name} → ${address || "NXDOMAIN"}`),
});
if (parking) out(`parked names → http://${parking.address}:${parking.port} → ${registryBase}/n/<name>`);
Expand Down Expand Up @@ -857,7 +882,7 @@ export async function dnsCommand(args = [], out = console.log) {
let plan;
try {
plan = sub === "enable"
? enablePlan({ platform, tlds, port: wanted, linuxBackend })
? enablePlan({ platform, tlds, port: wanted, linuxBackend, upstreams: await discoverUpstreams() })
: disablePlan({ platform, tlds, linuxBackend });
} catch (err) {
out(err.message);
Expand Down
34 changes: 34 additions & 0 deletions test/dns-catchall.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,37 @@ test("the shortfall reproduces the failure that started this", async () => {
assert.equal(shortfall.missing.length, 3496);
assert.equal(shortfall.missing[0], "t1090");
});

/* -------------------------------------- catch-all only when it is safe */

test("catch-all routing is written only when there is somewhere to forward", async () => {
const { enablePlan } = await import("../src/dns-system.mjs");
const conf = (plan) => plan.steps.find((s) => s.path?.includes("moshpit.conf"))?.content ?? "";

// With upstreams: one line that never grows.
const withUp = enablePlan({ platform: "linux", tlds: ["eggs", "hacker"], upstreams: ["67.207.67.3"] });
assert.match(conf(withUp), /^Domains=~\.$/m);

// Without: the per-ending list, which cannot take the machine's DNS with it.
// Getting this backwards sends every lookup to a bridge with nowhere to
// forward, and the whole box loses DNS rather than just Moshpit names.
const withoutUp = enablePlan({ platform: "linux", tlds: ["eggs", "hacker"], upstreams: [] });
assert.match(conf(withoutUp), /^Domains=~eggs ~hacker$/m);
assert.doesNotMatch(conf(withoutUp), /~\./);

// Same rule for dnsmasq.
const dnsmasqOn = enablePlan({ platform: "linux", linuxBackend: "dnsmasq", tlds: ["eggs"], upstreams: ["1.1.1.1"] });
assert.match(conf(dnsmasqOn), /^no-resolv$/m);
const dnsmasqOff = enablePlan({ platform: "linux", linuxBackend: "dnsmasq", tlds: ["eggs"], upstreams: [] });
assert.match(conf(dnsmasqOff), /^server=\/eggs\//m);
assert.doesNotMatch(dnsmasqOff.steps.map((s) => s.content).join(""), /no-resolv/);
});

test("upstreams are read before routing is switched, and loopback is dropped", async () => {
const { discoverUpstreams } = await import("../src/dns.mjs");
const resolv = "nameserver 127.0.0.53\nnameserver 67.207.67.3\nnameserver 67.207.67.2\n";
assert.deepEqual(await discoverUpstreams(async () => resolv), ["67.207.67.3", "67.207.67.2"]);
// An unreadable resolv.conf must read as "no upstreams", which keeps routing
// per-ending rather than pointing everything at a bridge that cannot forward.
assert.deepEqual(await discoverUpstreams(async () => { throw new Error("nope"); }), []);
});
Loading