From e0d24e22ab583688ec5e42b51ba472f6e2d20990 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 31 Jul 2026 03:28:11 +0000 Subject: [PATCH] feat(pit): setup instructions for the resolvers, on the site itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resolver nobody can find the instructions for resolves nothing. `/pit/dns` is the page a normal person lands on: the addresses to use, the setting to change on macOS, Windows, Linux, a router or a browser, and two commands to check it worked. The addresses come from MOSHPIT_DNS_RESOLVERS rather than the source, because they are operational facts that change when a box moves, and a page that keeps telling people to use an address that moved is worse than one that says nothing. Unset — which is today — the page says the resolvers are not published yet and explains how to run one. It never invents an address for a stranger to paste into their network settings, and entries that are not addresses are dropped rather than rendered. The page needs no database, so it stays up when the registry does not. Also says plainly what does not work: HTTPS on a Moshpit name warns, because no public CA will issue for a namespace outside the ICANN root, and clearnet lookups are forwarded to Google and Cloudflare. Both are reasons someone might run their own, which the page tells them how to do. /pit dropped the line about this shipping "until the resolver ships" — it has — and links here instead. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 8 ++ app/globals.css | 17 +++ app/pit/dns/page.tsx | 185 +++++++++++++++++++++++++++++++ app/pit/page.tsx | 11 +- docs/moshpit-dns.md | 22 +++- lib/moshpit-resolvers.ts | 65 +++++++++++ tests/moshpit-resolvers.test.mjs | 49 ++++++++ 7 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 app/pit/dns/page.tsx create mode 100644 lib/moshpit-resolvers.ts create mode 100644 tests/moshpit-resolvers.test.mjs diff --git a/.env.example b/.env.example index 336cee7..6753f8f 100644 --- a/.env.example +++ b/.env.example @@ -113,3 +113,11 @@ RAILWAY_API_TOKEN= # 'queries' logs one line per query (no client addresses are stored anywhere), # 'off' silences startup lines too. # MOSHPIT_DNS_LOG= + +# The resolvers advertised on pit.moshcode.sh/dns. Addresses, optionally named: +# MOSHPIT_DNS_RESOLVERS=dns1.pit.moshcode.sh=203.0.113.7,dns2.pit.moshcode.sh=203.0.113.8 +# Unset, the page says the resolvers are not published yet and explains how to +# run one — it never invents an address for someone to paste into their network +# settings. Set on the WEB app (the resolver process does not read these). +# MOSHPIT_DNS_RESOLVERS= +# MOSHPIT_DOH_URL=https://dns.pit.moshcode.sh/dns-query diff --git a/app/globals.css b/app/globals.css index 0f2d2cd..bab2b4a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -488,3 +488,20 @@ dialog.qhelp::backdrop { background: rgba(0,0,0,.72); backdrop-filter: blur(2px) font-family: var(--mono); background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 6px 12px; } + +/* ---- /pit/dns — resolver setup (PRD 0004 R1) ---- */ +.dns-addrs { list-style: none; display: flex; flex-wrap: wrap; gap: 12px; padding: 0; margin: 0 0 18px; } +.dns-addrs li { + display: flex; flex-direction: column; gap: 4px; + background: var(--panel); border: 1px solid var(--line); + border-left: 3px solid var(--acid); border-radius: 8px; padding: 12px 16px; +} +/* The address is the thing people copy, so it gets the size and the contrast. */ +.dns-ip { font-family: var(--mono); font-size: 1.25rem; color: var(--acid); user-select: all; } +.dns-host { font-family: var(--mono); font-size: .8rem; color: var(--ash); } +.dns-pre { + background: #0b0d0a; border: 1px solid var(--line); border-left: 3px solid var(--acid); + border-radius: 8px; padding: 14px 16px; overflow-x: auto; max-width: 62ch; + font-family: var(--mono); font-size: .88rem; line-height: 1.7; color: var(--bone); +} +.dns-pre code { font-family: inherit; background: none; padding: 0; } diff --git a/app/pit/dns/page.tsx b/app/pit/dns/page.tsx new file mode 100644 index 0000000..a65d287 --- /dev/null +++ b/app/pit/dns/page.tsx @@ -0,0 +1,185 @@ +import type { Metadata } from "next"; +import Nav from "@/components/Nav"; +import { resolverConfig } from "@/lib/moshpit-resolvers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Moshpit DNS — reach .moshpit without an extension", + description: + "Point your device at the Moshpit resolvers and custom TLDs resolve like any other name. The rest of the internet keeps working.", +}; + +/** + * pit.moshcode.sh/dns — the setup instructions for the public resolvers. + * + * The addresses come from the environment (see lib/moshpit-resolvers.ts): a + * page that hardcoded them would keep telling people to use a box that moved. + * When none are configured the page says so plainly and explains how to run + * one, because inventing an address for someone to paste into their network + * settings is worse than admitting the resolvers are not up yet. + */ +export default function MoshpitDnsPage() { + const { resolvers, doh, published } = resolverConfig(); + + return ( +
+
+ ); +} diff --git a/app/pit/page.tsx b/app/pit/page.tsx index 2cff31d..bb8764e 100644 --- a/app/pit/page.tsx +++ b/app/pit/page.tsx @@ -69,9 +69,14 @@ export default async function PitPage() {

// reaching a .moshpit address

These names live outside the traditional DNS root, so a normal browser doesn't - know where to look. This page is the way in until the resolver ships: names are - registered and looked up here over ordinary HTTPS, and{" "} - pit.moshcode.sh stays a working entry point for anyone without it. + know where to look. Two ways in.{" "} + + Point your device at the Moshpit resolvers + {" "} + and .anything resolves everywhere on that device — one setting, no + install, and the rest of the internet keeps working. Or skip it: names are + registered and looked up here over ordinary HTTPS, and pit.moshcode.sh{" "} + stays a working entry point for anyone who has changed nothing.

The browser extension that resolves .anything natively is not out yet. diff --git a/docs/moshpit-dns.md b/docs/moshpit-dns.md index 8b14955..6725873 100644 --- a/docs/moshpit-dns.md +++ b/docs/moshpit-dns.md @@ -98,9 +98,20 @@ dns2.pit A dns A ; DoH: https://dns.pit.moshcode.sh/dns-query ``` -These have to be **explicit** records. The wildcard that serves the HTTP -gateway (`*.pit.moshcode.sh`) would otherwise answer for `dns1.pit` and point -people's resolvers at a web server; an exact name always beats a wildcard. +These have to be **explicit** records, and they have to stay explicit. There is +no `*.pit.moshcode.sh` wildcard today — only `pit.moshcode.sh` itself resolves, +to Railway — but PRD `0004` R1 calls for one, and the day it is added it would +otherwise answer for `dns1.pit` and point people's resolvers at a web server. +An exact name always beats a wildcard, so publishing these first is what makes +that safe. + +Then tell the site about them, so `pit.moshcode.sh/dns` shows real addresses +instead of "not published yet": + +``` +MOSHPIT_DNS_RESOLVERS=dns1.pit.moshcode.sh=

,dns2.pit.moshcode.sh=
+MOSHPIT_DOH_URL=https://dns.pit.moshcode.sh/dns-query +``` Then set `MOSHPIT_GATEWAY_HOST=pit.moshcode.sh` (the default) and the resolvers follow the gateway wherever it moves, because they look its address up through @@ -112,6 +123,11 @@ a resolver on a Raspberry Pi for one household. Nothing here privileges ## Using one +The user-facing version of this section is a page on the site itself — +`pit.moshcode.sh/dns` (`app/pit/dns/page.tsx`) — which reads the addresses from +`MOSHPIT_DNS_RESOLVERS` and walks through the same steps without asking anyone +to read a repo. + **macOS** — System Settings → Network → your connection → Details → DNS, and add `dns1.pit.moshcode.sh`'s address as the first server. diff --git a/lib/moshpit-resolvers.ts b/lib/moshpit-resolvers.ts new file mode 100644 index 0000000..d7b670a --- /dev/null +++ b/lib/moshpit-resolvers.ts @@ -0,0 +1,65 @@ +// The public resolvers, as advertised on the site. +// +// Read from the environment rather than hardcoded, because the addresses are +// operational facts that change when a box moves, and a setup page that tells +// people to type a stale address is worse than one that tells them nothing. +// +// Addresses are validated here for the same reason: this list goes onto a page +// where strangers copy it into their network settings. A typo in an env var +// should show up as a missing entry, not as an instruction to point their DNS +// at something that is not an address at all. + +export type PublicResolver = { + /** A human name for the row, when the operator gave one. */ + name: string | null; + address: string; +}; + +const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/; + +export function isIpAddress(value: string): boolean { + const raw = String(value ?? "").trim(); + if (IPV4.test(raw)) return raw.split(".").every((octet) => Number(octet) <= 255); + // Loose on IPv6 by design: the exact grammar lives in the resolver's codec, + // and this only has to reject things that are obviously not addresses. + return /^[0-9a-f:]+$/i.test(raw) && raw.includes(":") && !raw.includes(":::"); +} + +/** + * Parse `dns1.pit.moshcode.sh=203.0.113.7, dns2.pit.moshcode.sh=203.0.113.8`. + * + * The name is optional — `203.0.113.7` on its own is a complete instruction, + * since what a person types into their DNS settings is an address. The name is + * there so the page can say which box they are pointing at. + */ +export function parseResolvers(spec: string | undefined | null): PublicResolver[] { + return String(spec ?? "") + .split(/[,\s]+/) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + const at = entry.lastIndexOf("="); + const name = at > 0 ? entry.slice(0, at).trim() : null; + const address = (at > 0 ? entry.slice(at + 1) : entry).trim(); + return { name: name || null, address }; + }) + .filter((resolver) => isIpAddress(resolver.address)); +} + +export type ResolverConfig = { + resolvers: PublicResolver[]; + /** The DoH endpoint, when one is published. */ + doh: string | null; + /** Whether there is anything to tell people to use yet. */ + published: boolean; +}; + +export function resolverConfig(env: Record = process.env): ResolverConfig { + const resolvers = parseResolvers(env.MOSHPIT_DNS_RESOLVERS); + const doh = (env.MOSHPIT_DOH_URL || "").trim(); + return { + resolvers, + doh: /^https:\/\/\S+$/.test(doh) ? doh : null, + published: resolvers.length > 0, + }; +} diff --git a/tests/moshpit-resolvers.test.mjs b/tests/moshpit-resolvers.test.mjs new file mode 100644 index 0000000..e7ddfca --- /dev/null +++ b/tests/moshpit-resolvers.test.mjs @@ -0,0 +1,49 @@ +// The resolver list goes onto a public page for strangers to copy into their +// network settings, so a malformed entry has to disappear rather than be +// rendered as an instruction. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isIpAddress, parseResolvers, resolverConfig } from "../lib/moshpit-resolvers.ts"; + +test("resolvers parse with or without a name", () => { + assert.deepEqual(parseResolvers("dns1.pit.moshcode.sh=203.0.113.7, 203.0.113.8"), [ + { name: "dns1.pit.moshcode.sh", address: "203.0.113.7" }, + { name: null, address: "203.0.113.8" }, + ]); +}); + +test("anything that is not an address is dropped, not printed", () => { + assert.deepEqual(parseResolvers("dns1=not-an-address, 203.0.113.999, =, 203.0.113.7"), [ + { name: null, address: "203.0.113.7" }, + ]); + assert.deepEqual(parseResolvers(""), []); + assert.deepEqual(parseResolvers(undefined), []); +}); + +test("IPv6 resolvers are accepted", () => { + assert.deepEqual(parseResolvers("dns1=2606:4700::1111"), [{ name: "dns1", address: "2606:4700::1111" }]); + assert.equal(isIpAddress("::1"), true); + assert.equal(isIpAddress("2606:4700:::1111"), false); + assert.equal(isIpAddress("hello"), false); +}); + +test("an unconfigured deployment says nothing is published rather than guessing", () => { + const config = resolverConfig({}); + assert.equal(config.published, false); + assert.deepEqual(config.resolvers, []); + assert.equal(config.doh, null); +}); + +test("the DoH endpoint has to be an https URL to be advertised", () => { + assert.equal(resolverConfig({ MOSHPIT_DOH_URL: "https://dns.pit.moshcode.sh/dns-query" }).doh, "https://dns.pit.moshcode.sh/dns-query"); + // Plain HTTP would be advertised as "secure DNS" in a browser settings pane. + assert.equal(resolverConfig({ MOSHPIT_DOH_URL: "http://dns.pit.moshcode.sh/dns-query" }).doh, null); + assert.equal(resolverConfig({ MOSHPIT_DOH_URL: "dns.pit.moshcode.sh" }).doh, null); +}); + +test("a configured deployment is published", () => { + const config = resolverConfig({ MOSHPIT_DNS_RESOLVERS: "dns1.pit.moshcode.sh=203.0.113.7" }); + assert.equal(config.published, true); + assert.equal(config.resolvers[0].address, "203.0.113.7"); +});