Skip to content

Commit 22c0d6c

Browse files
ralyodioclaude
andauthored
feat(dns): status reports what the resolver accepted, not what we wrote (#197)
The check that existed compared the config file against the registry -- two numbers we control, which agreed with each other -- and said nothing. The resolver is the one that gets a vote, and it silently declines to take them all: handed 4586 endings, systemd-resolved accepted 1090 alphabetically, rejected the rest one journal line at a time with "Argument list too long", and reported success. That is why `moshcode dns resolve chovy.hacker` answered while `curl chovy.hacker` could not resolve, with `dns status` calling the routing configured throughout. Nothing in the output distinguished an ending that routed from one that had been dropped. Run against this machine in that exact state, status now says: ! wrote 4586 endings, the resolver accepted 1090 — 3561 are not routed missing: criminology crip cripple cripples crips crochet … and 3553 more systemd-resolved caps how many search domains it takes and drops the rest: journalctl -u systemd-resolved | grep 'Argument list too long' a name in that list answers `moshcode dns resolve` and fails `curl`. The missing list starts at the alphabetical cut, which is the evidence: `.hacker` sits past it. Unknown is never "none". A machine with no resolvectl -- dnsmasq, macOS, anything not systemd -- gets null and is told nothing, rather than being told its routing is missing. Kept useful after the catch-all lands: `~.` cannot be truncated, so this stays quiet there and keeps reporting for anyone still on a per-ending config, which is every installed copy until they upgrade. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8cabe9c commit 22c0d6c

2 files changed

Lines changed: 109 additions & 3 deletions

File tree

src/dns.mjs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,59 @@ export function createServer(options = {}) {
500500

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

503+
/**
504+
* The routing suffixes the resolver actually accepted.
505+
*
506+
* Not the same question as what we wrote, which is the whole point. Writing a
507+
* config is not the same as the resolver honouring it, and systemd-resolved
508+
* caps how many search domains it will take: handed 4586 it accepted 1090
509+
* alphabetically, rejected the rest one journal line at a time with "Argument
510+
* list too long", and reported success. Status compared what it had written
511+
* against what the registry claimed, saw the same number twice, and said
512+
* everything was fine while 76% of endings did not resolve.
513+
*
514+
* So this asks the resolver instead of the file.
515+
*/
516+
export function parseResolvectlDomains(text) {
517+
const seen = new Set();
518+
for (const match of String(text ?? "").matchAll(/~([a-z0-9-]+)/gi)) {
519+
seen.add(match[1].toLowerCase());
520+
}
521+
return [...seen];
522+
}
523+
524+
/**
525+
* What routing the running resolver has, or null when we cannot ask it.
526+
*
527+
* Null is "unknown", never "none": a machine using dnsmasq, or not systemd at
528+
* all, has no resolvectl and must not be told its routing is missing.
529+
*/
530+
export async function acceptedDomains(runner) {
531+
const run = runner || (async () => {
532+
const { execFile } = await import("node:child_process");
533+
return new Promise((resolve) => {
534+
execFile("resolvectl", ["domain"], { timeout: 5000 }, (err, stdout) =>
535+
resolve(err ? null : String(stdout)));
536+
});
537+
});
538+
const output = await run().catch(() => null);
539+
return output === null || output === undefined ? null : parseResolvectlDomains(output);
540+
}
541+
542+
/**
543+
* Whether the resolver kept everything it was given, and what it dropped.
544+
*
545+
* `missing` is capped in what callers print, not here — the whole list is the
546+
* evidence, and an ending that is absent is exactly the thing someone is
547+
* searching the output for.
548+
*/
549+
export function routingShortfall(written, accepted) {
550+
if (!Array.isArray(accepted)) return null;
551+
const have = new Set(accepted);
552+
const missing = written.filter((tld) => !have.has(tld));
553+
return { written: written.length, accepted: accepted.length, missing };
554+
}
555+
503556
/**
504557
* systemd-resolved drop-in routing just the Moshpit TLDs at the bridge.
505558
*
@@ -887,10 +940,23 @@ export async function dnsCommand(args = [], out = console.log) {
887940
// — a name claimed after you enabled simply does not resolve.
888941
if (routed && known && platform === "linux") {
889942
const conf = await readFile(marker, "utf8").catch(() => "");
890-
const routedCount = (conf.match(/~[a-z0-9-]+/g) || []).length;
891-
if (routedCount && routedCount !== known.length) {
943+
const written = [...new Set((conf.match(/~[a-z0-9-]+/g) || []).map((t) => t.slice(1).toLowerCase()))];
944+
if (written.length && written.length !== known.length) {
945+
out("");
946+
out(`! routing covers ${written.length} TLDs but ${known.length} are claimed — re-run \`sudo moshcode dns enable\``);
947+
}
948+
949+
// The check that was missing. Comparing the file against the registry
950+
// compares two things we control and agrees with itself; the resolver is
951+
// the one that gets a vote, and it silently declines to take them all.
952+
const shortfall = routingShortfall(written, await acceptedDomains());
953+
if (shortfall && shortfall.missing.length) {
892954
out("");
893-
out(`! routing covers ${routedCount} TLDs but ${known.length} are claimed — re-run \`sudo moshcode dns enable\``);
955+
out(`! wrote ${shortfall.written} endings, the resolver accepted ${shortfall.accepted}${shortfall.missing.length} are not routed`);
956+
out(` missing: ${shortfall.missing.slice(0, 8).join(" ")}${shortfall.missing.length > 8 ? ` … and ${shortfall.missing.length - 8} more` : ""}`);
957+
out(" systemd-resolved caps how many search domains it takes and drops the rest:");
958+
out(" journalctl -u systemd-resolved | grep 'Argument list too long'");
959+
out(" a name in that list answers `moshcode dns resolve` and fails `curl`.");
894960
}
895961
}
896962
return 0;

test/dns-catchall.test.mjs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,43 @@ test("loopback nameservers are dropped when finding upstreams", () => {
203203
assert.deepEqual(parseUpstreams(""), []);
204204
assert.deepEqual(parseUpstreams(null), []);
205205
});
206+
207+
/* ---------------------------------------- noticing that the resolver said no */
208+
209+
test("routingShortfall names what the resolver refused to take", async () => {
210+
const { parseResolvectlDomains, routingShortfall, acceptedDomains } = await import("../src/dns.mjs");
211+
212+
// Verbatim shape of `resolvectl domain`: a Global line, wrapped, plus links.
213+
const output = [
214+
"Global: ~eggs ~oranges ~2600",
215+
" ~abex ~acid",
216+
"Link 2 (eth0): ~eggs",
217+
].join("\n");
218+
assert.deepEqual(parseResolvectlDomains(output).sort(), ["2600", "abex", "acid", "eggs", "oranges"]);
219+
220+
// The real failure: written and claimed agreed, so the old check was silent.
221+
const written = ["eggs", "oranges", "hacker", "rank", "zombies"];
222+
const shortfall = routingShortfall(written, ["eggs", "oranges"]);
223+
assert.equal(shortfall.written, 5);
224+
assert.equal(shortfall.accepted, 2);
225+
assert.deepEqual(shortfall.missing, ["hacker", "rank", "zombies"]);
226+
227+
// Everything accepted is not a shortfall.
228+
assert.deepEqual(routingShortfall(written, written).missing, []);
229+
230+
// Unknown is never "none": a box without resolvectl must not be told its
231+
// routing is missing.
232+
assert.equal(routingShortfall(written, null), null);
233+
assert.equal(await acceptedDomains(async () => null), null);
234+
assert.deepEqual(await acceptedDomains(async () => "Global: ~eggs"), ["eggs"]);
235+
});
236+
237+
test("the shortfall reproduces the failure that started this", async () => {
238+
const { routingShortfall } = await import("../src/dns.mjs");
239+
// 4586 written, 1090 accepted, alphabetically — which is how ~hacker went
240+
// missing while `moshcode dns resolve chovy.hacker` kept answering.
241+
const written = Array.from({ length: 4586 }, (_, i) => `t${String(i).padStart(4, "0")}`);
242+
const shortfall = routingShortfall(written, written.slice(0, 1090));
243+
assert.equal(shortfall.missing.length, 3496);
244+
assert.equal(shortfall.missing[0], "t1090");
245+
});

0 commit comments

Comments
 (0)