diff --git a/.env.example b/.env.example index 336cee7..9204fef 100644 --- a/.env.example +++ b/.env.example @@ -113,3 +113,10 @@ RAILWAY_API_TOKEN= # 'queries' logs one line per query (no client addresses are stored anywhere), # 'off' silences startup lines too. # MOSHPIT_DNS_LOG= + +# Catch-all: resolve names nobody has claimed, under endings the legacy root +# does not have, so `mosh.whatever` lands on the pit instead of an error page. +# Never fires for a real TLD (`asdkjh.com` stays NXDOMAIN) — that boundary is +# checked against the root, not assumed. Off by default: it answers for names +# the registry never granted, which is a product decision. +# MOSHPIT_DNS_CATCHALL=1 diff --git a/deploy/Caddyfile.gateway b/deploy/Caddyfile.gateway index 89d7194..6e92445 100644 --- a/deploy/Caddyfile.gateway +++ b/deploy/Caddyfile.gateway @@ -35,16 +35,17 @@ redir https://pit.moshcode.sh{uri} permanent } + # Any other Host is a Moshpit name that resolved here because it has no + # site of its own yet. Send it to the pit with the name filled in: whoever + # typed `mosh.whatever` has just demonstrated demand for that name, and the + # pit can offer them the ending it sits under. + # + # A redirect rather than a proxy, deliberately. Proxying would leave people + # signing in under a hostname no certificate authority will vouch for, with + # a session cookie on a domain the app does not own. The redirect puts them + # on the real origin, with a real padlock, where signing in works. handle { - rewrite * /pit - reverse_proxy https://pit.moshcode.sh { - # Railway routes on Host, so the upstream request has to carry the - # name Railway knows; the Moshpit name travels alongside it for - # whatever serves the name once the grid is real. - header_up Host pit.moshcode.sh - header_up X-Moshpit-Name {host} - header_up X-Forwarded-Host {host} - } + redir https://app.moshcode.sh/pit?name={host} 302 } log { diff --git a/docs/moshpit-dns.md b/docs/moshpit-dns.md index 8b14955..e6a85d5 100644 --- a/docs/moshpit-dns.md +++ b/docs/moshpit-dns.md @@ -155,13 +155,46 @@ namespace's whole premise is that anyone can invent one. Verified with `curl -H 'Host: fuck.yeah' http:///`. So a deployment needs an ingress that accepts any Host, on the same box as a -resolver, with `MOSHPIT_GATEWAY_A` pointed at it. -`deploy/Caddyfile.gateway` is that ingress: it serves the clearnet preview -page for the name (PRD `0004` R11) until the hosting grid exists to proxy into. +resolver, with `MOSHPIT_GATEWAY_A` pointed at it. `deploy/Caddyfile.gateway` is +that ingress. Until the hosting grid exists to proxy into, it sends the visitor +to the pit with the name they typed: + +``` +mosh.whatever -> 302 -> https://app.moshcode.sh/pit?name=mosh.whatever +``` + +A redirect rather than a proxy, deliberately: proxying would leave people +signing in under a hostname no CA will vouch for, with a session cookie on a +domain the app does not own. The redirect puts them on the real origin, with a +real padlock, where signing in works — and the pit then offers them the ending +the name sits under. None of this changes the resolver — that is why the gateway address is a setting rather than a constant. +## The catch-all + +By default a name nobody has claimed gets clearnet's verdict, which is +NXDOMAIN — an error page. `MOSHPIT_DNS_CATCHALL=1` changes that: an unclaimed +name under an ending **the legacy root does not have** resolves to the gateway, +and the visitor lands on the pit with `mosh.whatever` filled in, one form away +from holding the ending it sits under. + +The boundary is the entire feature. `asdkjh.com` is NXDOMAIN too, and answering +that one would make this resolver a typo-squatter for the whole internet — the +behaviour ISPs were rightly hated for. So the catch-all fires only when the TLD +itself is absent from the root, which is checked by asking the upstreams for the +TLD's SOA (one cached query per ending, not per name) rather than by shipping an +IANA list that would be stale within the week. An unreachable upstream fails +closed: unknown means "the root has it". + +Answers are marked: `aa` is 0, because nobody holds the name and claiming +authority over it would be a lie, and a `TXT` lookup says `unclaimed=1`. + +Off by default. It makes the resolver answer for names the registry never +granted, and that is a product decision rather than something a resolver should +assume on your behalf. + ## What this does not fix **HTTPS on a Moshpit name.** No public CA will issue a certificate for diff --git a/lib/dns/answers.ts b/lib/dns/answers.ts index f023b62..9d87f81 100644 --- a/lib/dns/answers.ts +++ b/lib/dns/answers.ts @@ -32,6 +32,8 @@ export type RegistryLookup = { exempt?: boolean; /** Where the owner points the name, when they point it somewhere specific. */ target?: string | null; + /** Nobody holds this name — the catch-all is sending them to claim it. */ + unclaimed?: boolean; }; /** @@ -156,7 +158,8 @@ export function moshpitAnswer(opts: { ttl, txt: [ `v=moshpit1 name=${lookup.name} resolved=${lookup.resolved} ` + - `${pointer.kind === "none" ? `gateway=${gateway.host}` : `target=${pointer.value}`}`, + `${pointer.kind === "none" ? `gateway=${gateway.host}` : `target=${pointer.value}`}` + + `${lookup.unclaimed ? " unclaimed=1" : ""}`, ], }); } else if (question.type === TYPE.SOA) { diff --git a/lib/dns/roots.ts b/lib/dns/roots.ts new file mode 100644 index 0000000..fb016e0 --- /dev/null +++ b/lib/dns/roots.ts @@ -0,0 +1,79 @@ +// Does the legacy root know this TLD? +// +// The catch-all needs this and nothing else. Sending someone who typed +// `mosh.whatever` to the pit is a good answer, because `.whatever` is a TLD the +// old internet does not have and nobody could have meant anything else. Doing +// the same for `asdkjh.com` would be typo-squatting the entire internet — the +// behaviour ISPs were rightly hated for — so the difference has to be checked, +// not assumed. +// +// Checked by asking the upstreams rather than by shipping an IANA list: a +// bundled list is stale the week after it is written, and this question is one +// cached query per TLD, not per name. + +import type { Forwarder } from "./upstream"; +import { CLASS, RCODE, TYPE, decodeMessage, encodeMessage } from "./wire"; + +export type RootProbe = { + /** True when the legacy root has this TLD. Unknown counts as "yes". */ + exists(tld: string): Promise; +}; + +export function createRootProbe(options: { + forwarder: Forwarder; + /** How long an answer is trusted. New TLDs are rare; this can be hours. */ + ttlMs?: number; + now?: () => number; + randomId?: () => number; +}): RootProbe { + const ttlMs = options.ttlMs ?? 3_600_000; + const now = options.now ?? Date.now; + const randomId = options.randomId ?? (() => Math.floor(Math.random() * 0x10000)); + + const cache = new Map(); + const inflight = new Map>(); + + async function probe(tld: string): Promise { + const payload = encodeMessage({ + id: randomId(), + flags: { qr: false, opcode: 0, aa: false, tc: false, rd: true, ra: false, z: false, ad: false, cd: false, rcode: 0 }, + // SOA at the TLD apex: every real TLD has one, and it is a single + // question rather than a walk down the tree. + questions: [{ name: tld, type: TYPE.SOA, class: CLASS.IN }], + }); + + try { + const response = decodeMessage(await options.forwarder.query(payload)); + // NXDOMAIN is the only answer that means "this TLD is not in the root". + // NOERROR — with or without records — means it exists, and a SERVFAIL + // means we do not know. + const missing = response.flags.rcode === RCODE.NXDOMAIN; + const value = !missing; + cache.set(tld, { value, expires: now() + (missing ? ttlMs : ttlMs * 24) }); + return value; + } catch { + // Unreachable upstream must not turn into "this TLD does not exist", + // which would hand the entire internet to the catch-all. Fail closed, + // and retry soon rather than caching the failure for an hour. + cache.set(tld, { value: true, expires: now() + 10_000 }); + return true; + } + } + + return { + async exists(rawTld: string) { + const tld = String(rawTld ?? "").trim().toLowerCase().replace(/\.$/, ""); + if (!tld) return true; + + const hit = cache.get(tld); + if (hit && hit.expires > now()) return hit.value; + + const existing = inflight.get(tld); + if (existing) return existing; + + const promise = probe(tld).finally(() => inflight.delete(tld)); + inflight.set(tld, promise); + return promise; + }, + }; +} diff --git a/lib/dns/server.ts b/lib/dns/server.ts index 198f8e2..dae3828 100644 --- a/lib/dns/server.ts +++ b/lib/dns/server.ts @@ -11,6 +11,7 @@ import net from "node:net"; import { moshpitAnswer, type GatewayAddresses } from "./answers"; import type { GatewayResolver } from "./gateway"; import { clearnetAnswered, planQuery, type ResolveMode } from "./policy"; +import type { RootProbe } from "./roots"; import type { RateLimiter } from "./ratelimit"; import type { RegistryClient } from "./registry"; import type { Forwarder } from "./upstream"; @@ -30,6 +31,8 @@ export type ServerStats = { moshpit: number; forwarded: number; refused: number; + /** Unclaimed names sent to the pit, when the catch-all is on. */ + catchall: number; failed: number; dropped: number; malformed: number; @@ -53,6 +56,14 @@ export type DnsServerOptions = { address?: string; port?: number; rateLimiter?: RateLimiter; + /** + * Answer for names nobody holds, under TLDs the legacy root does not have, + * so a typed-in name lands on the pit instead of an error page. Off by + * default: it makes the resolver answer for names the registry never + * granted, which is a product decision, not a default. + */ + catchAll?: boolean; + rootProbe?: RootProbe; log?: (line: string) => void; randomId?: () => number; }; @@ -75,6 +86,8 @@ export function createDnsServer(options: DnsServerOptions): DnsServer { // the kernel picks the UDP port, and TCP then has to follow it. let port = options.port ?? 53; const address = options.address ?? "0.0.0.0"; + const catchAll = Boolean(options.catchAll); + const rootProbe = options.rootProbe ?? null; const log = options.log ?? (() => {}); const randomId = options.randomId ?? (() => Math.floor(Math.random() * 0x10000)); @@ -83,6 +96,7 @@ export function createDnsServer(options: DnsServerOptions): DnsServer { moshpit: 0, forwarded: 0, refused: 0, + catchall: 0, failed: 0, dropped: 0, malformed: 0, @@ -177,6 +191,47 @@ export function createDnsServer(options: DnsServerOptions): DnsServer { return { buffer: encodeMessage(message), message }; } + /** + * The answer for a name nobody has claimed, under a TLD the legacy root does + * not have. + * + * Off by default, and gated on the root probe rather than on "clearnet said + * NXDOMAIN". Those are not the same question: `asdkjh.com` is also NXDOMAIN, + * and answering that one would make this resolver a typo-squatter for the + * entire internet instead of a door into the namespace. + */ + async function catchAllAnswer(query: Message, name: string): Promise { + if (!catchAll || !rootProbe) return null; + const tld = name.split(".").pop() ?? ""; + if (!tld || (await rootProbe.exists(tld))) return null; + + let addresses: GatewayAddresses; + try { + addresses = await gateway.addresses(); + } catch { + addresses = gateway.current(); + } + if (!addresses.ipv4.length && !addresses.ipv6.length) return null; + + const message = moshpitAnswer({ + id: query.id, + question: query.questions[0], + rd: query.flags.rd, + // Registered as far as the answer is concerned — the gateway is a real + // place to send them — but with no target, so it is the gateway's + // addresses they get and the gateway that decides what to show. + lookup: { name, resolved: name, registered: true, unclaimed: true }, + gateway: addresses, + ttl: Math.min(ttl, 60), + }); + if (!message) return null; + message.additionals = echoOpt(query); + // Not authoritative: nobody holds this name, and saying otherwise would + // claim an authority the registry never granted. + message.flags.aa = false; + return encodeMessage(message); + } + async function handle(rawQuery: Buffer, ctx: QueryContext = { transport: "udp" }): Promise { stats.queries++; const started = Date.now(); @@ -271,6 +326,18 @@ export function createDnsServer(options: DnsServerOptions): DnsServer { stats.moshpit++; return finish("moshpit(backfill)", answer.buffer); } + + // Nobody holds the name and clearnet has never heard of it. With the + // catch-all on, that is not a dead end but the most interesting visitor + // the namespace gets: someone who typed a name that could still be + // theirs. Send them to the gateway, which lands them on the pit with the + // name filled in. + const unclaimed = await catchAllAnswer(query, plan.name); + if (unclaimed) { + stats.catchall++; + return finish("catchall(unclaimed)", unclaimed); + } + stats.forwarded++; return finish("forwarded(no moshpit name)", relayed); } catch (err) { diff --git a/scripts/moshpit-dns.ts b/scripts/moshpit-dns.ts index 0feecb2..a457746 100644 --- a/scripts/moshpit-dns.ts +++ b/scripts/moshpit-dns.ts @@ -16,6 +16,7 @@ import { createGatewayResolver } from "../lib/dns/gateway"; import { DEFAULT_RESOLVE_MODE, type ResolveMode } from "../lib/dns/policy"; import { createRateLimiter } from "../lib/dns/ratelimit"; import { createRegistryClient, DEFAULT_REGISTRY_BASE } from "../lib/dns/registry"; +import { createRootProbe } from "../lib/dns/roots"; import { createDnsServer } from "../lib/dns/server"; import { createForwarder, parseUpstreams } from "../lib/dns/upstream"; @@ -65,11 +66,19 @@ const gateway = createGatewayResolver({ ipv6: list(env.MOSHPIT_GATEWAY_AAAA), }); +// Off unless asked for: with it on, a name nobody holds under an ending the +// legacy root does not have resolves to the gateway, which lands the visitor on +// the pit with the name filled in. That is a funnel, and a funnel is a product +// decision rather than a default a resolver should assume. +const catchAll = /^(1|true|yes|on)$/i.test(env.MOSHPIT_DNS_CATCHALL ?? ""); + const dns = createDnsServer({ registry, forwarder, gateway, mode, + catchAll, + rootProbe: catchAll ? createRootProbe({ forwarder }) : undefined, ttl: number(env.MOSHPIT_DNS_TTL, 60), address, port, @@ -83,6 +92,7 @@ const dns = createDnsServer({ const ports = await dns.listen(); log(`listening on ${address}:${ports.udp} (udp) and ${address}:${ports.tcp} (tcp), mode=${mode}`); log(`registry ${registryBase} · gateway ${gatewayHost} · upstreams ${upstreams.map((u) => u.host).join(", ")}`); +if (catchAll) log("catch-all ON — unclaimed names under non-root endings resolve to the gateway"); // Warm the gateway addresses at boot rather than on the first query, so the // first person through the door does not pay for the lookup. diff --git a/tests/dns-catchall.test.mjs b/tests/dns-catchall.test.mjs new file mode 100644 index 0000000..874ef5d --- /dev/null +++ b/tests/dns-catchall.test.mjs @@ -0,0 +1,144 @@ +// The catch-all is the difference between "that name does not exist" and "that +// name could be yours". It is also one rule away from being a typo-squatter for +// the whole internet, so the boundary gets tested harder than the feature. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createGatewayResolver } from "../lib/dns/gateway.ts"; +import { createRegistryClient } from "../lib/dns/registry.ts"; +import { createRootProbe } from "../lib/dns/roots.ts"; +import { createDnsServer } from "../lib/dns/server.ts"; +import { createForwarder } from "../lib/dns/upstream.ts"; +import { CLASS, RCODE, TYPE, decodeMessage, encodeMessage } from "../lib/dns/wire.ts"; + +const GATEWAY_V4 = "203.0.113.7"; + +const query = (name, type = TYPE.A) => + encodeMessage({ id: 0x77, flags: { rd: true }, questions: [{ name, type, class: CLASS.IN }] }); + +/** + * An upstream that knows a set of real TLDs and a set of real names. Anything + * else is NXDOMAIN — including a SOA query for an ending the root does not + * have, which is exactly how the root probe tells them apart. + */ +function stubUpstream({ realTlds = ["com", "org"], zone = {} } = {}) { + return async (_upstream, payload) => { + const q = decodeMessage(payload); + const question = q.questions[0]; + const isRealTld = question.type === TYPE.SOA && realTlds.includes(question.name); + const address = zone[question.name]; + const found = isRealTld || address; + return encodeMessage({ + id: q.id, + flags: { qr: true, rd: true, ra: true, rcode: found ? RCODE.NOERROR : RCODE.NXDOMAIN }, + questions: q.questions, + answers: address ? [{ name: question.name, type: TYPE.A, class: CLASS.IN, ttl: 300, address }] : [], + }); + }; +} + +function harness({ catchAll = true, names = {}, zone = {}, realTlds } = {}) { + const forwarder = createForwarder({ ask: stubUpstream({ realTlds, zone }) }); + const registry = createRegistryClient({ + base: "http://registry.test", + fetchImpl: async (url) => { + const name = new URL(url).searchParams.get("name"); + return new Response(JSON.stringify({ name, resolved: name, registered: Boolean(names[name]) }), { status: 200 }); + }, + }); + return createDnsServer({ + registry, + forwarder, + gateway: createGatewayResolver({ host: "pit.moshcode.sh", forwarder, ipv4: [GATEWAY_V4], ipv6: [] }), + catchAll, + rootProbe: createRootProbe({ forwarder }), + port: 0, + address: "127.0.0.1", + }); +} + +const addresses = (msg) => msg.answers.filter((r) => r.type === TYPE.A).map((r) => r.address); + +test("an unclaimed name under an ending the root does not have goes to the pit", async () => { + const dns = harness(); + const response = decodeMessage(await dns.handle(query("mosh.whatever"))); + + assert.equal(response.flags.rcode, RCODE.NOERROR); + assert.deepEqual(addresses(response), [GATEWAY_V4]); + assert.equal(response.flags.aa, false, "nobody holds this name — claiming authority over it would be a lie"); + assert.equal(dns.stats().catchall, 1); + await dns.close(); +}); + +test("a name that does not exist under a REAL TLD is left alone", async () => { + // This is the whole boundary: `asdkjh.com` is NXDOMAIN too, and answering it + // would make this resolver a typo-squatter for the entire internet. + const dns = harness(); + const response = decodeMessage(await dns.handle(query("asdkjh.com"))); + + assert.equal(response.flags.rcode, RCODE.NXDOMAIN); + assert.equal(dns.stats().catchall, 0); + await dns.close(); +}); + +test("with the catch-all off, an unclaimed name is still NXDOMAIN", async () => { + const dns = harness({ catchAll: false }); + assert.equal(decodeMessage(await dns.handle(query("mosh.whatever"))).flags.rcode, RCODE.NXDOMAIN); + assert.equal(dns.stats().catchall, 0); + await dns.close(); +}); + +test("a claimed name still answers as itself, not as a catch-all", async () => { + const dns = harness({ names: { "scrambled.eggs": true } }); + const response = decodeMessage(await dns.handle(query("scrambled.eggs"))); + + assert.equal(response.flags.aa, true, "a registered name IS authoritative"); + assert.equal(dns.stats().moshpit, 1); + assert.equal(dns.stats().catchall, 0); + await dns.close(); +}); + +test("clearnet still wins whenever clearnet has an answer", async () => { + const dns = harness({ zone: { "example.com": "198.51.100.9" } }); + assert.deepEqual(addresses(decodeMessage(await dns.handle(query("example.com")))), ["198.51.100.9"]); + assert.equal(dns.stats().catchall, 0); + await dns.close(); +}); + +test("the TXT record says the name is unclaimed, so nobody thinks they own it", async () => { + const dns = harness(); + const response = decodeMessage(await dns.handle(query("mosh.whatever", TYPE.TXT))); + assert.match(response.answers.find((r) => r.type === TYPE.TXT).txt[0], /unclaimed=1/); + await dns.close(); +}); + +test("the root is asked once per ending, not once per name", async () => { + let soaQueries = 0; + const forwarder = createForwarder({ + ask: async (_u, payload) => { + const q = decodeMessage(payload); + if (q.questions[0].type === TYPE.SOA) soaQueries++; + return encodeMessage({ + id: q.id, + flags: { qr: true, rd: true, ra: true, rcode: RCODE.NXDOMAIN }, + questions: q.questions, + }); + }, + }); + const probe = createRootProbe({ forwarder }); + assert.equal(await probe.exists("whatever"), false); + assert.equal(await probe.exists("whatever"), false); + assert.equal(soaQueries, 1, "cached"); + forwarder.close(); +}); + +test("an unreachable root fails closed, so an outage cannot hand over the internet", async () => { + const forwarder = createForwarder({ + ask: async () => { + throw new Error("network down"); + }, + }); + const probe = createRootProbe({ forwarder }); + assert.equal(await probe.exists("whatever"), true, "unknown must mean 'the root has it'"); + forwarder.close(); +});