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
72 changes: 69 additions & 3 deletions src/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,59 @@ export function createServer(options = {}) {

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

/**
* The routing suffixes the resolver actually accepted.
*
* Not the same question as what we wrote, which is the whole point. Writing a
* config is not the same as the resolver honouring it, and systemd-resolved
* caps how many search domains it will take: handed 4586 it accepted 1090
* alphabetically, rejected the rest one journal line at a time with "Argument
* list too long", and reported success. Status compared what it had written
* against what the registry claimed, saw the same number twice, and said
* everything was fine while 76% of endings did not resolve.
*
* So this asks the resolver instead of the file.
*/
export function parseResolvectlDomains(text) {
const seen = new Set();
for (const match of String(text ?? "").matchAll(/~([a-z0-9-]+)/gi)) {
seen.add(match[1].toLowerCase());
}
return [...seen];
}

/**
* What routing the running resolver has, or null when we cannot ask it.
*
* Null is "unknown", never "none": a machine using dnsmasq, or not systemd at
* all, has no resolvectl and must not be told its routing is missing.
*/
export async function acceptedDomains(runner) {
const run = runner || (async () => {
const { execFile } = await import("node:child_process");
return new Promise((resolve) => {
execFile("resolvectl", ["domain"], { timeout: 5000 }, (err, stdout) =>
resolve(err ? null : String(stdout)));
});
});
const output = await run().catch(() => null);
return output === null || output === undefined ? null : parseResolvectlDomains(output);
}

/**
* Whether the resolver kept everything it was given, and what it dropped.
*
* `missing` is capped in what callers print, not here — the whole list is the
* evidence, and an ending that is absent is exactly the thing someone is
* searching the output for.
*/
export function routingShortfall(written, accepted) {
if (!Array.isArray(accepted)) return null;
const have = new Set(accepted);
const missing = written.filter((tld) => !have.has(tld));
return { written: written.length, accepted: accepted.length, missing };
}

/**
* systemd-resolved drop-in routing just the Moshpit TLDs at the bridge.
*
Expand Down Expand Up @@ -887,10 +940,23 @@ export async function dnsCommand(args = [], out = console.log) {
// — a name claimed after you enabled simply does not resolve.
if (routed && known && platform === "linux") {
const conf = await readFile(marker, "utf8").catch(() => "");
const routedCount = (conf.match(/~[a-z0-9-]+/g) || []).length;
if (routedCount && routedCount !== known.length) {
const written = [...new Set((conf.match(/~[a-z0-9-]+/g) || []).map((t) => t.slice(1).toLowerCase()))];
if (written.length && written.length !== known.length) {
out("");
out(`! routing covers ${written.length} TLDs but ${known.length} are claimed — re-run \`sudo moshcode dns enable\``);
}

// The check that was missing. Comparing the file against the registry
// compares two things we control and agrees with itself; the resolver is
// the one that gets a vote, and it silently declines to take them all.
const shortfall = routingShortfall(written, await acceptedDomains());
if (shortfall && shortfall.missing.length) {
out("");
out(`! routing covers ${routedCount} TLDs but ${known.length} are claimed — re-run \`sudo moshcode dns enable\``);
out(`! wrote ${shortfall.written} endings, the resolver accepted ${shortfall.accepted} — ${shortfall.missing.length} are not routed`);
out(` missing: ${shortfall.missing.slice(0, 8).join(" ")}${shortfall.missing.length > 8 ? ` … and ${shortfall.missing.length - 8} more` : ""}`);
out(" systemd-resolved caps how many search domains it takes and drops the rest:");
out(" journalctl -u systemd-resolved | grep 'Argument list too long'");
out(" a name in that list answers `moshcode dns resolve` and fails `curl`.");
}
}
return 0;
Expand Down
40 changes: 40 additions & 0 deletions test/dns-catchall.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,43 @@ test("loopback nameservers are dropped when finding upstreams", () => {
assert.deepEqual(parseUpstreams(""), []);
assert.deepEqual(parseUpstreams(null), []);
});

/* ---------------------------------------- noticing that the resolver said no */

test("routingShortfall names what the resolver refused to take", async () => {
const { parseResolvectlDomains, routingShortfall, acceptedDomains } = await import("../src/dns.mjs");

// Verbatim shape of `resolvectl domain`: a Global line, wrapped, plus links.
const output = [
"Global: ~eggs ~oranges ~2600",
" ~abex ~acid",
"Link 2 (eth0): ~eggs",
].join("\n");
assert.deepEqual(parseResolvectlDomains(output).sort(), ["2600", "abex", "acid", "eggs", "oranges"]);

// The real failure: written and claimed agreed, so the old check was silent.
const written = ["eggs", "oranges", "hacker", "rank", "zombies"];
const shortfall = routingShortfall(written, ["eggs", "oranges"]);
assert.equal(shortfall.written, 5);
assert.equal(shortfall.accepted, 2);
assert.deepEqual(shortfall.missing, ["hacker", "rank", "zombies"]);

// Everything accepted is not a shortfall.
assert.deepEqual(routingShortfall(written, written).missing, []);

// Unknown is never "none": a box without resolvectl must not be told its
// routing is missing.
assert.equal(routingShortfall(written, null), null);
assert.equal(await acceptedDomains(async () => null), null);
assert.deepEqual(await acceptedDomains(async () => "Global: ~eggs"), ["eggs"]);
});

test("the shortfall reproduces the failure that started this", async () => {
const { routingShortfall } = await import("../src/dns.mjs");
// 4586 written, 1090 accepted, alphabetically — which is how ~hacker went
// missing while `moshcode dns resolve chovy.hacker` kept answering.
const written = Array.from({ length: 4586 }, (_, i) => `t${String(i).padStart(4, "0")}`);
const shortfall = routingShortfall(written, written.slice(0, 1090));
assert.equal(shortfall.missing.length, 3496);
assert.equal(shortfall.missing[0], "t1090");
});
Loading