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"); +});