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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 10 additions & 9 deletions deploy/Caddyfile.gateway
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
39 changes: 36 additions & 3 deletions docs/moshpit-dns.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,46 @@ namespace's whole premise is that anyone can invent one. Verified with
`curl -H 'Host: fuck.yeah' http://<pit address>/`.

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
Expand Down
5 changes: 4 additions & 1 deletion lib/dns/answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand Down Expand Up @@ -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) {
Expand Down
79 changes: 79 additions & 0 deletions lib/dns/roots.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
};

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<string, { value: boolean; expires: number }>();
const inflight = new Map<string, Promise<boolean>>();

async function probe(tld: string): Promise<boolean> {
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;
},
};
}
67 changes: 67 additions & 0 deletions lib/dns/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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;
};
Expand All @@ -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));

Expand All @@ -83,6 +96,7 @@ export function createDnsServer(options: DnsServerOptions): DnsServer {
moshpit: 0,
forwarded: 0,
refused: 0,
catchall: 0,
failed: 0,
dropped: 0,
malformed: 0,
Expand Down Expand Up @@ -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<Buffer | null> {
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<Buffer | null> {
stats.queries++;
const started = Date.now();
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions scripts/moshpit-dns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
Loading
Loading