diff --git a/src/dns-service.mjs b/src/dns-service.mjs index efca84a..f928722 100644 --- a/src/dns-service.mjs +++ b/src/dns-service.mjs @@ -33,6 +33,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; +import { operatorHome } from "./trust.mjs"; export const UNIT_NAME = "moshcode-dns.service"; @@ -217,7 +218,7 @@ export function proxyServicePaths() { export function proxyServiceUnit({ wrapper, nodeDir, - home = homedir(), + home = operatorHome(), user = process.env.SUDO_USER || process.env.USER || process.env.LOGNAME, port = 443, tlds = [], @@ -273,7 +274,7 @@ export function proxyServiceUnit({ } /** Where moshpit-proxy's installer puts its wrapper, if it ran. */ -export function proxyWrapperPath({ home = homedir(), exists = existsSync } = {}) { +export function proxyWrapperPath({ home = operatorHome(), exists = existsSync } = {}) { const candidate = join(home, ".local/bin/moshpit-proxy"); return exists(candidate) ? candidate : null; } @@ -293,7 +294,7 @@ export function proxyWrapperPath({ home = homedir(), exists = existsSync } = {}) * answer, so the wait is generous. */ export async function ensureProxyService({ - home = homedir(), + home = operatorHome(), user = process.env.SUDO_USER || process.env.USER || process.env.LOGNAME, nodeDir = dirname(process.execPath), tlds = [], diff --git a/src/trust.mjs b/src/trust.mjs index 48e8cbc..843aee5 100644 --- a/src/trust.mjs +++ b/src/trust.mjs @@ -151,8 +151,24 @@ export function requireNameConstraints(text, { tlds = [] } = {}) { const permits = (tld) => constraints.permitted.some((entry) => bare(entry) === String(tld).toLowerCase()); const missing = tlds.filter((tld) => !permits(tld)); - if (missing.length) { - return { ok: false, kind: "out-of-step", why: `the root does not permit ${missing.join(", ")}` }; + // Not a refusal, and this was the single most damaging line in the file. + // + // The root is minted by moshpit-proxy for the endings it was configured to + // serve — a handful. `tlds` is every ending the registry has sold, which is + // 18224 and climbing. So this could never pass on a real machine: it refused + // to install a perfectly good root because it did not also cover 18000 + // endings nobody on that machine was trying to reach, and printed all of them + // as evidence. The advice it gave — regenerate the root — cannot help, + // because the new root is scoped to the same handful. + // + // A root that covers some of what you resolve is worth exactly what it + // covers. So it goes in, and the shortfall is a count rather than a wall. + if (missing.length === tlds.length) { + return { + ok: false, + kind: "out-of-step", + why: `the root permits none of the endings claimed (${summarise(missing)}) — it is for a different namespace`, + }; } // And nothing beyond them. One `DNS:.com` in the permitted subtree is the // whole hole this gate exists to close, and it would otherwise sail through @@ -163,10 +179,32 @@ export function requireNameConstraints(text, { tlds = [] } = {}) { return { ok: false, kind: "out-of-step", - why: `the root also permits ${foreign.join(", ")}, which is not an ending we resolve — it reaches past Moshpit`, + why: `the root also permits ${summarise(foreign)}, which is not an ending we resolve — it reaches past Moshpit`, }; } - return { ok: true, why: "constrained to Moshpit endings" }; + // Carried, not printed here: the caller decides how loud a partial root is, + // and it is the only place that knows whether the person asked about a name + // the root actually covers. + return missing.length + ? { + ok: true, + why: `constrained to Moshpit endings — covers ${tlds.length - missing.length} of ${tlds.length}`, + missing, + } + : { ok: true, why: "constrained to Moshpit endings" }; +} + +/** + * A list a person can read, rather than one that fills the terminal. + * + * A registry with 18224 endings turns any "these are wrong" message into + * several screens of text, which is not a diagnostic — it is the reason nobody + * reads the line above it. + */ +export function summarise(items, show = 8) { + const list = [...items]; + if (list.length <= show) return list.join(", "); + return `${list.slice(0, show).join(", ")} and ${list.length - show} more`; } /** diff --git a/test/trust.test.mjs b/test/trust.test.mjs index c9feab9..1ab6919 100644 --- a/test/trust.test.mjs +++ b/test/trust.test.mjs @@ -161,12 +161,49 @@ test("constraints that are not critical do not count", () => { assert.match(verdict.why, /critical/); }); -test("a root that does not permit the endings we resolve is refused", (t) => { +test("a root that permits none of the endings we resolve is refused, and names them", (t) => { const text = needRoot(t, MOSHPIT_ONLY); if (!text) return; - const verdict = requireNameConstraints(text, { tlds: ["hacker", "eggs"] }); + // The root is for `.hacker` and `.rank`. Asked about a namespace it shares + // nothing with, it is the wrong root, not a partial one. + const verdict = requireNameConstraints(text, { tlds: ["eggs", "2600"] }); assert.equal(verdict.ok, false); assert.match(verdict.why, /eggs/); + assert.match(verdict.why, /2600/); +}); + +test("a root that covers some of what we resolve goes in, and says how much", (t) => { + const text = needRoot(t, MOSHPIT_ONLY); + if (!text) return; + // This is the case a real machine is always in. The root is minted by + // moshpit-proxy for the endings it serves — a handful — while the registry + // has sold 18224. Refusing here meant refusing always, and the refusal + // printed all 18224 as evidence. + const verdict = requireNameConstraints(text, { tlds: ["hacker", "rank", "eggs"] }); + assert.equal(verdict.ok, true, "a root that covers `.hacker` is worth installing for `.hacker`"); + assert.deepEqual(verdict.missing, ["eggs"]); + assert.match(verdict.why, /covers 2 of 3/); +}); + +test("a root reaching past Moshpit is still refused", (t) => { + // The gate that actually matters, and the one this change must not weaken: + // `.rank` is permitted by the root but is not an ending being resolved here, + // so the root can vouch for names outside the namespace it is trusted for. + const text = needRoot(t, MOSHPIT_ONLY); + if (!text) return; + const verdict = requireNameConstraints(text, { tlds: ["hacker"] }); + assert.equal(verdict.ok, false); + assert.match(verdict.why, /reaches past Moshpit/); + assert.match(verdict.why, /rank/); +}); + +test("a refusal names a few endings, never thousands", () => { + // 18224 endings turn any diagnostic into several screens, which is how the + // line explaining the problem stops being read. + const many = Array.from({ length: 500 }, (_, i) => `e${i}`); + const verdict = requireNameConstraints("", { tlds: many }); + assert.equal(verdict.ok, false); + assert.ok(verdict.why.length < 400, `a refusal must stay readable, got ${verdict.why.length} chars`); }); test("nothing usable from openssl is a refusal, not a pass", () => {