From 77a4c79d8858c113b765e5690d6a82044b628d78 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 5 Aug 2026 04:34:25 +0000 Subject: [PATCH] feat(dns): resolve third-level names through an owner's wildcard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Syncs the vendored bridge with @moshcoder/moshpit-dns 0.5.0. The devDependency was pinned at ^0.3.0 while the package had reached 0.4.1, so the drift test — whose whole job is to make divergence loud — had been comparing against a version two releases behind and passing. The namespace is no longer one level deep. `www.chovy.hacker` is asked of the registry as written, and a name it does not hold falls back to the owner's published `*.chovy.hacker`. A sub-name that misses both is NXDOMAIN rather than parked: parking says a name is for sale, and a name under someone else's name is not, so parking it would advertise their subdomains to a stranger. Two things the port had to get right beyond copying: The timeout is per ask rather than per call. The wildcard fallback is a second request, and the vendored copy's single AbortController would have handed it whatever was left of the first one's budget — sometimes nothing. `dns resolve` prints from a map keyed by status, and nothing had taught it the new `nxdomain` one, so `explain[result.status]()` threw a TypeError over the top of the answer. The full suite stayed green through it because every other resolve test asks for --json, which never touches the human branch. Fixed, and covered by a test that walks every status the resolver can return. Forwarding is unaffected: `isOurs` still gates on the ending, so `www.google.com` is still someone else's to answer. Four labels remain a shape the registry cannot hold. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 2 +- pnpm-lock.yaml | 10 +-- src/dns.mjs | 102 ++++++++++++++++------ test/dns-nodata.test.mjs | 4 +- test/dns-records.test.mjs | 12 +-- test/dns-resolve-json.test.mjs | 53 +++++++++++- test/dns-subdomains.test.mjs | 152 +++++++++++++++++++++++++++++++++ test/dns.test.mjs | 9 +- 8 files changed, 303 insertions(+), 41 deletions(-) create mode 100644 test/dns-subdomains.test.mjs diff --git a/package.json b/package.json index 86d3cd0..c76fc1b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,6 @@ "license": "MIT", "packageManager": "pnpm@10.32.1", "devDependencies": { - "@moshcoder/moshpit-dns": "^0.3.0" + "@moshcoder/moshpit-dns": "^0.5.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8a11a6..96767ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,16 +9,16 @@ importers: .: devDependencies: '@moshcoder/moshpit-dns': - specifier: ^0.3.0 - version: 0.3.0 + specifier: ^0.5.0 + version: 0.5.0 packages: - '@moshcoder/moshpit-dns@0.3.0': - resolution: {integrity: sha512-BGkcSHniErFXp1uLoKN+97L1zIqh2nzmOlZJTQ0Lpap+iR6Qk+8gc+w9KKHFJBAd7pqMiGO+Ry/RzOqqvHozyw==} + '@moshcoder/moshpit-dns@0.5.0': + resolution: {integrity: sha512-cl0wj5iKdsJrA8lE1SXcCcaVcIqzp4O9rhkMJ6m+K/NN+4RPyqxw+9q+JWDRFUVuXGnLHmkY78RtyN2k6u66Ng==} engines: {node: '>=20'} hasBin: true snapshots: - '@moshcoder/moshpit-dns@0.3.0': {} + '@moshcoder/moshpit-dns@0.5.0': {} diff --git a/src/dns.mjs b/src/dns.mjs index 323508c..d0b1176 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -378,13 +378,17 @@ export function buildResponse(query, buf, address, ttl = DEFAULT_TTL, exists = B /* ------------------------------------------------------------------ registry */ -/** Names the registry can hold: exactly one label and one TLD. */ +/** + * Names the registry can hold: exactly one label and one TLD, or a third label + * under such a name — including `*` as the whole leftmost label, the wildcard + * an owner publishes for everything under their name. + */ export function parseRegistryName(hostname) { const host = String(hostname || "").trim().toLowerCase().replace(/\.$/, ""); if (!host || host.includes(":")) return null; if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null; const parts = host.split("."); - if (parts.length !== 2) return null; + if (parts.length !== 2 && parts.length !== 3) return null; // Letters and digits only, matching the registry. A dash is the cheapest way // to mint a look-alike of an ending someone else holds, and in a namespace // one level deep and first come first served there is nowhere to retreat to. @@ -392,6 +396,14 @@ export function parseRegistryName(hostname) { // rule itself: a name this bridge accepts and the registry rejects resolves // to a page that says it does not exist. const LABEL = /^[a-z0-9]{1,63}$/; + if (parts.length === 3) { + const [sub, label, tld] = parts; + // `*` is a label only whole and only leftmost — `f*.chovy.hacker` and + // `foo.*.hacker` are not names the registry can be asked about. + if (sub !== "*" && !LABEL.test(sub)) return null; + if (!LABEL.test(label) || !LABEL.test(tld)) return null; + return { sub, label, tld }; + } const [label, tld] = parts; if (!LABEL.test(label) || !LABEL.test(tld)) return null; return { label, tld }; @@ -450,41 +462,72 @@ export async function fetchTlds({ registryBase = DEFAULT_REGISTRY_BASE, fetchImp * name with no target is NOT an error, it is a name waiting to be pointed * somewhere. Handing back the parking host means `curl california.oranges` * reaches a page that explains itself instead of failing to resolve. + * + * A third-level name adds a fourth: it exists only through its parent or a + * wildcard the parent published, so missing both is NXDOMAIN — there is + * nothing to park it to. */ export async function resolveName( name, { registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch, timeoutMs = 4000, records = false } = {}, ) { const parsed = parseRegistryName(name); - if (!parsed) return { status: "not-a-name", target: null, records: [] }; + if (!parsed) return { status: "not-a-name", target: null }; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); + const full = `${parsed.sub ? `${parsed.sub}.` : ""}${parsed.label}.${parsed.tld}`; try { // `&records=1` only when the question needs the whole set. Every address // lookup on the machine comes through here, and the registry does a second // query to answer it — a browser opening a page must not pay for records it // will never read. - const url = `${registryBase.replace(/\/+$/, "")}/api/moshpit/resolve?name=${encodeURIComponent( - `${parsed.label}.${parsed.tld}`, - )}${records ? "&records=1" : ""}`; - const res = await fetchImpl(url, { signal: controller.signal }); - if (!res.ok) return { status: "unreachable", target: null }; - const json = await res.json(); - const claimed = - typeof json?.name_registered === "boolean" ? json.name_registered : json?.registered; - if (typeof claimed !== "boolean") return { status: "unreachable", target: null }; - // The `records` key appears only when it was asked for. Every caller that - // wants an address deep-compares this shape, and an empty array they never - // requested is a difference they would have to be taught to ignore. - const found = records ? { records: Array.isArray(json.records) ? json.records : [] } : {}; - const target = typeof json.target === "string" && json.target ? json.target : null; - if (target) return { status: "live", target, ...found }; - return { status: "parked", target: null, registered: claimed, ...found }; + // + // The timeout is per ask rather than per call: the wildcard fallback below + // is a second request, and a budget shared with the first would give it + // whatever was left over — sometimes nothing. + const ask = async (asked) => { + const url = `${registryBase.replace(/\/+$/, "")}/api/moshpit/resolve?name=${encodeURIComponent( + asked, + )}${records ? "&records=1" : ""}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetchImpl(url, { signal: controller.signal }); + if (!res.ok) return { status: "unreachable", target: null }; + const json = await res.json(); + const claimed = + typeof json?.name_registered === "boolean" ? json.name_registered : json?.registered; + if (typeof claimed !== "boolean") return { status: "unreachable", target: null }; + // The `records` key appears only when it was asked for. Every caller that + // wants an address deep-compares this shape, and an empty array they never + // requested is a difference they would have to be taught to ignore. + const found = records ? { records: Array.isArray(json.records) ? json.records : [] } : {}; + const target = typeof json.target === "string" && json.target ? json.target : null; + if (target) return { status: "live", target, ...found }; + return { status: "parked", target: null, registered: claimed, ...found }; + } finally { + clearTimeout(timer); + } + }; + + let result = await ask(full); + // A third-level name the registry does not hold may still be covered by a + // wildcard its owner published. The registry applies that match itself; + // asking for the literal `*.parent` is the fallback for one old enough to + // only know the wildcard as a name of its own. A bare label keeps parking + // on a miss — a sub-name has nothing to park to, so missing everywhere is + // NXDOMAIN. The answer keeps the asked name either way: the wire codec + // writes the question's name into every owner field, as a wildcard answer + // should. + const missed = (r) => r.status === "parked" && r.registered === false; + if (parsed.sub && missed(result)) { + if (parsed.sub !== "*") result = await ask(`*.${parsed.label}.${parsed.tld}`); + if (missed(result)) { + return { status: "nxdomain", target: null, ...(records ? { records: [] } : {}) }; + } + } + return result; } catch { return { status: "unreachable", target: null }; - } finally { - clearTimeout(timer); } } @@ -661,12 +704,15 @@ export async function addressAnswer(name, options = {}) { if (!exists) return { exists: false, kind: "nxdomain", records: [], address: null, cname: null }; // Parking is checked before anything the registry published: a parked name's - // whole job is to reach the page explaining that it is for sale. + // whole job is to reach the page explaining that it is for sale. A third-level + // name is never for sale — it exists only through a wildcard its parent + // published — so "parked" there means the wildcard has no target, and the + // records it published are the answer. // // It is also checked before the proxy, deliberately. A parked name has no // origin and no published pin, so handing it to a proxy whose entire job is // to verify one would turn "this name is for sale" into a TLS error. - if (result.status === "parked") { + if (result.status === "parked" && !parseRegistryName(name)?.sub) { return parkingAddress ? plan("address", { address: parkingAddress }) : plan("nodata"); } @@ -2135,7 +2181,11 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { live: () => `${name} → ${result.target}`, parked: () => `${name} → ${pitUrl} [parked — claimed but not pointed at an IP]`, unreachable: () => `${name} → NXDOMAIN [registry unreachable — not parking a name we could not look up]`, - "not-a-name": () => `${name} → NXDOMAIN [not a Moshpit name: needs exactly one label and one TLD]`, + // A third-level name that missed both itself and its parent's wildcard. + // Distinct from parked on purpose: a name under someone else's name is + // not for sale, so there is no page to send anyone to. + nxdomain: () => `${name} → NXDOMAIN [no such name, and its parent publishes no wildcard covering it]`, + "not-a-name": () => `${name} → NXDOMAIN [not a Moshpit name: needs one label and one TLD, or one more label under such a name]`, }; if (asJson) { out(JSON.stringify({ diff --git a/test/dns-nodata.test.mjs b/test/dns-nodata.test.mjs index f55c762..7b8a940 100644 --- a/test/dns-nodata.test.mjs +++ b/test/dns-nodata.test.mjs @@ -143,7 +143,9 @@ test("a name the registry cannot hold is still NXDOMAIN, on every type", async ( const reg = parked(); const server = await serve(t, reg); for (const type of [TYPE_A, TYPE_HTTPS]) { - const reply = await ask(server, query("deep.sub.eggs", { type })); + // Four labels. A third is a name under a name and the registry can answer + // for it through a wildcard, so the unholdable shape is one level deeper. + const reply = await ask(server, query("deeper.deep.sub.eggs", { type })); assert.equal(rcode(reply), RCODE_NXDOMAIN, `type ${type} on a non-name`); } assert.equal(reg.calls.length, 0, "a shape the registry cannot hold is never looked up"); diff --git a/test/dns-records.test.mjs b/test/dns-records.test.mjs index 6499424..3568996 100644 --- a/test/dns-records.test.mjs +++ b/test/dns-records.test.mjs @@ -225,10 +225,11 @@ test("an unregistered name under a claimed ending is parked, not denied", async }); test("a record question about something that is not a name at all is NXDOMAIN", async (t) => { - // Three labels cannot be a Moshpit name — the namespace is one level deep — - // so there is nothing here to be waiting to be pointed. + // Four labels cannot be a Moshpit name. Three can — that is a name under a + // name, answered through the owner's wildcard — so the shape that has nothing + // waiting to be pointed is one level deeper than it used to be. const server = await serve(t, registry({ records: [] })); - const reply = await ask(server, query("not.a.name", { type: TYPE_TXT })); + const reply = await ask(server, query("not.a.real.name", { type: TYPE_TXT })); assert.equal(rcode(reply), RCODE_NXDOMAIN); }); @@ -280,9 +281,10 @@ test("answerRecords separates 'no such record' from 'no such name'", async () => assert.deepEqual(await answerRecords("blue.eggs", { fetchImpl: here.fetchImpl, type: "MX" }), { exists: true, records: [] }); - // Not a name this registry can be asked about at all. + // Not a name this registry can be asked about at all — four labels, one + // deeper than the third-level names an owner's wildcard covers. const reg = registry({ records: [] }); - assert.deepEqual(await answerRecords("not.a.name", { fetchImpl: reg.fetchImpl, type: "MX" }), + assert.deepEqual(await answerRecords("not.a.real.name", { fetchImpl: reg.fetchImpl, type: "MX" }), { exists: false, records: [] }); assert.equal(reg.calls.length, 0, "a name it could reject on sight still cost a round trip"); }); diff --git a/test/dns-resolve-json.test.mjs b/test/dns-resolve-json.test.mjs index 97ceb32..0eee1f3 100644 --- a/test/dns-resolve-json.test.mjs +++ b/test/dns-resolve-json.test.mjs @@ -82,15 +82,64 @@ test("JSON preserves failure statuses and exit codes", async (t) => { await t.test("invalid Moshpit name", async () => { globalThis.fetch = async () => { throw new Error("must not fetch"); }; - const { code, value } = await run("three.part.name"); + // Four parts. Three is a name under a name now, and would be looked up. + const { code, value } = await run("four.part.long.name"); assert.equal(code, 1); assert.deepEqual(value, { - name: "three.part.name", + name: "four.part.long.name", status: "not-a-name", target: null, pitUrl: null, }); }); + + await t.test("a sub-name under no wildcard", async () => { + registryResponse({ name_registered: false, target: null }); + const { code, value } = await run("www.chovy.hacker"); + assert.equal(code, 1); + assert.deepEqual(value, { + name: "www.chovy.hacker", + status: "nxdomain", + target: null, + pitUrl: null, + }); + }); +}); + +test("every status resolve can report has something to print", async (t) => { + // Without this, a status the resolver learned to return but the printer was + // never taught crashes the command: `explain[status]` is undefined and the + // call throws a TypeError over the top of the answer. That is how `nxdomain` + // shipped broken — every other test here asks for --json, which never touches + // the human branch. + const cases = [ + ["live.eggs", { name_registered: true, target: "203.0.113.7" }], + ["parked.eggs", { name_registered: true, target: null }], + ["www.chovy.hacker", { name_registered: false, target: null }], + ]; + for (const [name, body] of cases) { + await t.test(name, async () => { + registryResponse(body); + const output = []; + await dnsCommand(["resolve", name, "--registry", REGISTRY], (line) => output.push(String(line))); + assert.ok(output.length >= 1, "said nothing at all"); + assert.match(output[0], new RegExp(`^${name.replace(/\./g, "\\.")} → `)); + }); + } + + await t.test("unreachable registry", async () => { + globalThis.fetch = async () => { throw new Error("offline"); }; + const output = []; + await dnsCommand(["resolve", "lost.eggs", "--registry", REGISTRY], (line) => output.push(String(line))); + assert.match(output[0], /^lost\.eggs → /); + }); + + await t.test("not a name at all", async () => { + globalThis.fetch = async () => { throw new Error("must not fetch"); }; + const output = []; + await dnsCommand(["resolve", "four.part.long.name", "--registry", REGISTRY], (line) => output.push(String(line))); + assert.match(output[0], /^four\.part\.long\.name → /); + }); }); test("--open never mixes human messages into JSON stdout", async () => { diff --git a/test/dns-subdomains.test.mjs b/test/dns-subdomains.test.mjs new file mode 100644 index 0000000..29bea73 --- /dev/null +++ b/test/dns-subdomains.test.mjs @@ -0,0 +1,152 @@ +/** + * Names under a name, and the wildcard that covers them. + * + * The namespace used to be exactly one level deep, so `www.chovy.hacker` was + * not a name at all and never cost a lookup. It is one now: the registry holds + * third-level names, and an owner can publish `*.chovy.hacker` to cover every + * name under theirs at once. + * + * Two things here are easy to get wrong and expensive when wrong. A sub-name + * that nobody holds must be NXDOMAIN rather than parked — parking exists to + * say a name is for sale, and a name under someone else's name is not for sale, + * so parking it would advertise the owner's subdomains to a stranger. And the + * wildcard fallback must not fire for a name that already is the wildcard, or + * every miss costs the registry two round trips instead of one. + * + * The last test is moshcode's alone: the vendored bridge also has proxy mode, + * which the published package does not, and a third-level name has to reach it + * like any other live name. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { addressAnswer, resolveName } from "../src/dns.mjs"; + +/** + * A registry that answers per name, and records what it was asked. + * + * Keyed by the name as the bridge asks for it, so a wildcard entry is written + * `*.chovy.hacker` — which is exactly how the fallback asks for it. + */ +function registry(byName = {}) { + const asked = []; + return { + asked, + fetchImpl: async (url) => { + const name = decodeURIComponent(new URL(url).searchParams.get("name")); + asked.push(name); + const body = byName[name]; + return { + ok: true, + json: async () => (body + // `name_registered: false` is how the registry says it does not hold + // a name — the shape a miss takes, not an error. + ? { name_registered: true, ...body } + : { name_registered: false, target: null }), + }; + }, + }; +} + +test("a third-level name the registry holds resolves on the first ask", async () => { + const reg = registry({ "www.chovy.hacker": { target: "203.0.113.7" } }); + const r = await resolveName("www.chovy.hacker", { fetchImpl: reg.fetchImpl }); + + assert.equal(r.status, "live"); + assert.equal(r.target, "203.0.113.7"); + assert.deepEqual(reg.asked, ["www.chovy.hacker"], "a name it holds must not also cost a wildcard ask"); +}); + +test("a third-level name nobody holds falls back to the owner's wildcard", async () => { + const reg = registry({ "*.chovy.hacker": { target: "203.0.113.9" } }); + const r = await resolveName("www.chovy.hacker", { fetchImpl: reg.fetchImpl }); + + assert.equal(r.status, "live"); + assert.equal(r.target, "203.0.113.9"); + assert.deepEqual(reg.asked, ["www.chovy.hacker", "*.chovy.hacker"]); +}); + +test("a sub-name missing everywhere is NXDOMAIN, not parked", async () => { + // The one that matters. Parking a name under someone else's name would put a + // for-sale page on every subdomain a stranger cares to guess. + const reg = registry({}); + const r = await resolveName("www.chovy.hacker", { fetchImpl: reg.fetchImpl }); + + assert.equal(r.status, "nxdomain"); + assert.equal(r.target, null); +}); + +test("the wildcard itself does not ask twice", async () => { + const reg = registry({}); + const r = await resolveName("*.chovy.hacker", { fetchImpl: reg.fetchImpl }); + + assert.equal(r.status, "nxdomain"); + assert.deepEqual(reg.asked, ["*.chovy.hacker"], "a wildcard that missed has no wildcard to fall back to"); +}); + +test("a bare name that nobody holds is still parked, not NXDOMAIN", async () => { + // The rule above must not leak into two-label names: a name waiting to be + // pointed is the whole reason parking exists. + const reg = registry({}); + const r = await resolveName("california.oranges", { fetchImpl: reg.fetchImpl }); + + assert.equal(r.status, "parked"); + assert.equal(r.registered, false); +}); + +test("a sub-name under a wildcard with no target does not reach the parking page", async () => { + // "Parked" on a third-level name means the wildcard exists but points + // nowhere. There is nothing for sale here, so what the owner published is the + // answer rather than the for-sale page. + const reg = registry({ + "*.chovy.hacker": { + target: null, + records: [{ type: "A", value: "203.0.113.4", ttl: 300, priority: null }], + }, + }); + const plan = await addressAnswer("www.chovy.hacker", { + fetchImpl: reg.fetchImpl, + parkingAddress: "198.51.100.9", + }); + + assert.equal(plan.exists, true); + assert.notEqual(plan.address, "198.51.100.9", "a name under a name is not for sale"); + assert.equal(plan.kind, "records"); + assert.deepEqual(plan.records.map((r) => r.value), ["203.0.113.4"]); +}); + +test("a bare parked name still reaches the parking page", async () => { + const reg = registry({ "california.oranges": { target: null } }); + const plan = await addressAnswer("california.oranges", { + fetchImpl: reg.fetchImpl, + parkingAddress: "198.51.100.9", + }); + + assert.equal(plan.address, "198.51.100.9"); +}); + +test("a live third-level name answers the proxy like any other", async () => { + // moshcode's own addition. A subdomain needs a verifiable certificate exactly + // as much as the name above it, so proxy mode must not skip it. + const reg = registry({ "*.chovy.hacker": { target: "203.0.113.9" } }); + const plan = await addressAnswer("www.chovy.hacker", { + fetchImpl: reg.fetchImpl, + proxyAddress: { v4: "127.0.0.1", v6: "::1" }, + }); + + assert.equal(plan.address, "127.0.0.1"); + assert.equal(plan.proxied, true); +}); + +test("a sub-name nobody holds is NXDOMAIN even with the proxy on", async () => { + // Proxy mode answers every live name, and this one is not live. Pointing it + // at the proxy would turn a name that does not exist into a TLS error. + const reg = registry({}); + const plan = await addressAnswer("www.chovy.hacker", { + fetchImpl: reg.fetchImpl, + proxyAddress: { v4: "127.0.0.1", v6: "::1" }, + }); + + assert.equal(plan.exists, false); + assert.equal(plan.kind, "nxdomain"); +}); diff --git a/test/dns.test.mjs b/test/dns.test.mjs index d10ed06..430f956 100644 --- a/test/dns.test.mjs +++ b/test/dns.test.mjs @@ -80,7 +80,14 @@ test("buildResponse says NXDOMAIN when there is no address", () => { test("only registry-shaped names are ours to answer", () => { assert.deepEqual(parseRegistryName("california.oranges"), { label: "california", tld: "oranges" }); - assert.equal(parseRegistryName("a.b.c"), null); + // A third label is a name under a name, which the registry holds through the + // owner's wildcard. A fourth is not a shape it can hold at all. + assert.deepEqual(parseRegistryName("a.b.c"), { sub: "a", label: "b", tld: "c" }); + assert.deepEqual(parseRegistryName("*.chovy.hacker"), { sub: "*", label: "chovy", tld: "hacker" }); + assert.equal(parseRegistryName("deep.sub.chovy.hacker"), null); + // `*` is a label only whole and only leftmost. + assert.equal(parseRegistryName("f*.chovy.hacker"), null); + assert.equal(parseRegistryName("foo.*.hacker"), null); assert.equal(parseRegistryName("localhost"), null); assert.equal(parseRegistryName("127.0.0.1"), null); });