Skip to content

Commit 460fa95

Browse files
ralyodioclaude
andcommitted
feat(pit): serve a name at an IPv6 address, and say how on the page
A name could be pointed at an IPv6 address and then not work, in two different ways, neither of which said so. The bridge only answered A. An AAAA query got an empty answer and the target went into `ipv4()`, which returned null, which is NXDOMAIN — so a name pointed at a v6 address did not resolve at all. It now answers both families, and an A query for a v6-only name comes back NOERROR with no answers rather than NXDOMAIN: browsers ask for both, and NXDOMAIN on the A half is entitled to take the AAAA answer down with it. The gateway built `http://2606:4700::1111:80/`, which is not a URL — the address's own colons are indistinguishable from the port separator, so fetch threw and every v6 name 504'd as "the origin could not be reached". `checkTarget` now returns the origin with the host bracketed. Targets are validated when they are written rather than only when they are fetched, and IPv4 literals are refused: an A record on a small host is usually leased or NATed, and a name pointed at one goes stale without telling anyone. Hostnames still work. Storing an unroutable address now fails at the form instead of minting a name that 502s for every visitor. Also adds the instructions to the page the field is on, with the Caddy and nginx blocks — including the `http://` Caddy needs, since no public CA will issue for an ending that is not in the DNS root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3d2e20c commit 460fa95

8 files changed

Lines changed: 363 additions & 28 deletions

File tree

apps/pwa/src/lib/moshpit-gateway.mjs

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,67 @@ export function parseTarget(target) {
104104
return { host: raw, port: 80 };
105105
}
106106

107+
/** A hostname target: dotted, and every label a legal DNS label. */
108+
const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/i;
109+
110+
/**
111+
* Bracket an IPv6 host so it can go in a URL.
112+
*
113+
* `http://2606:4700::1111:80/` is not a URL — the colons in the address are
114+
* indistinguishable from the port separator, and `new URL` rejects it. Every
115+
* place that turns a target back into a URL has to go through here.
116+
*/
117+
export function urlHost(host) {
118+
return isIP(host) === 6 ? `[${host}]` : host;
119+
}
120+
121+
/** The origin URL to fetch, with an IPv6 host bracketed. */
122+
export function originUrl({ host, port }) {
123+
return `http://${urlHost(host)}:${port}`;
124+
}
125+
126+
/**
127+
* Validate what an owner typed into "points at", and return the form to store.
128+
*
129+
* IP literals must be IPv6. An A record is a commitment to an address that a
130+
* name's owner usually does not own for long — IPv4 on a small host is leased,
131+
* NATed, or shared, and a name pointed at one goes stale silently. Every host
132+
* worth pointing a Moshpit name at has a stable /64 to spare, so the registry
133+
* asks for the address that will still be theirs next month. Hostnames stay
134+
* allowed: the address behind them is someone else's problem to keep current.
135+
*
136+
* Empty is not an error. A name with no target is a name waiting to be pointed,
137+
* which is the state every name starts in and a state owners return it to.
138+
*/
139+
export function normalizeTarget(input) {
140+
const raw = String(input ?? "").trim();
141+
if (!raw) return { ok: true, target: null };
142+
143+
const parsed = parseTarget(raw);
144+
if (!parsed) return { ok: false, error: "not a usable target" };
145+
146+
const version = isIP(parsed.host);
147+
if (version === 4) {
148+
return {
149+
ok: false,
150+
error: "IPv4 addresses are not accepted — point the name at an IPv6 address, or at a hostname",
151+
};
152+
}
153+
154+
if (version === 6) {
155+
const why = blockedReason(parsed.host);
156+
if (why) return { ok: false, error: `that address is ${why} — a target has to be reachable from the public internet` };
157+
// Stored bare when it is just an address, so anything reading the column
158+
// gets something it can use as an address without unwrapping it first.
159+
// Brackets appear only when a port forces them to.
160+
return { ok: true, target: parsed.port === 80 ? parsed.host.toLowerCase() : `[${parsed.host.toLowerCase()}]:${parsed.port}` };
161+
}
162+
163+
if (!HOSTNAME.test(parsed.host)) return { ok: false, error: "not a usable target" };
164+
const host = parsed.host.toLowerCase();
165+
return { ok: true, target: parsed.port === 80 ? host : `${host}:${parsed.port}` };
166+
}
167+
107168
/**
108169
* Is this target safe to fetch, and at what address?
109170
*
@@ -118,7 +179,7 @@ export async function checkTarget(target, { resolve = dns.lookup } = {}) {
118179
const why = blockedReason(parsed.host);
119180
return why
120181
? { ok: false, error: `target is ${why}` }
121-
: { ok: true, host: parsed.host, port: parsed.port, addresses: [parsed.host] };
182+
: { ok: true, host: parsed.host, port: parsed.port, origin: originUrl(parsed), addresses: [parsed.host] };
122183
}
123184

124185
let addresses;
@@ -133,7 +194,13 @@ export async function checkTarget(target, { resolve = dns.lookup } = {}) {
133194
const why = blockedReason(address);
134195
if (why) return { ok: false, error: `target resolves to ${why}` };
135196
}
136-
return { ok: true, host: parsed.host, port: parsed.port, addresses: addresses.map((a) => a.address) };
197+
return {
198+
ok: true,
199+
host: parsed.host,
200+
port: parsed.port,
201+
origin: originUrl(parsed),
202+
addresses: addresses.map((a) => a.address),
203+
};
137204
}
138205

139206
/** Headers worth passing to the origin. Everything else is dropped. */

apps/pwa/src/moshpit.mjs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
// checkable rather than trusted.
1212

1313
import { db, get, all, run } from "./db.mjs";
14+
import { normalizeTarget } from "./lib/moshpit-gateway.mjs";
1415
import {
1516
BULK_CHUNK,
1617
BULK_TIME_BUDGET_MS,
@@ -325,9 +326,15 @@ export async function registerName({ tld: tldInput, label: labelInput, userId, t
325326
if (!owner) return { ok: false, error: `.${tld} is not registered` };
326327
if (owner.user_id !== userId) return { ok: false, error: `you do not own .${tld}` };
327328

329+
// Checked on the way in, not only on the way out. A target that fails is a
330+
// name that looks minted and serves nothing, and the owner finds out from a
331+
// visitor rather than from the form they typed it into.
332+
const dest = normalizeTarget(target);
333+
if (!dest.ok) return { ok: false, error: dest.error };
334+
328335
try {
329336
await run(`INSERT INTO moshpit_names (tld, label, user_id, target, created_at) VALUES (?,?,?,?,?)`,
330-
[tld, label, userId, target || null, Date.now()]);
337+
[tld, label, userId, dest.target, Date.now()]);
331338
} catch {
332339
const existing = await getName(tld, label);
333340
if (existing) return { ok: false, error: `${label}.${tld} is already registered`, taken: true };
@@ -342,8 +349,10 @@ export async function registerName({ tld: tldInput, label: labelInput, userId, t
342349
export async function setNameTarget({ tld: tldInput, label: labelInput, userId, target }) {
343350
const owned = await ownedName(tldInput, labelInput, userId);
344351
if (!owned.ok) return owned;
352+
const dest = normalizeTarget(target);
353+
if (!dest.ok) return { ok: false, error: dest.error };
345354
await run(`UPDATE moshpit_names SET target = ? WHERE tld = ? AND label = ?`,
346-
[target || null, owned.tld, owned.label]);
355+
[dest.target, owned.tld, owned.label]);
347356
await logAction(owned.tld, userId, `retarget:${owned.label}`);
348357
return { ok: true };
349358
}

apps/pwa/src/routes/moshpit.mjs

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ async function proxyToOrigin(req, res, resolution, check) {
470470
const controller = new AbortController();
471471
const timer = setTimeout(() => controller.abort(), ORIGIN_TIMEOUT_MS);
472472
try {
473-
const upstream = await fetch(`http://${check.host}:${check.port}${req.originalUrl.replace(/^\/n\/[^/?]+/, "") || "/"}`, {
473+
const upstream = await fetch(`${check.origin}${req.originalUrl.replace(/^\/n\/[^/?]+/, "") || "/"}`, {
474474
headers: forwardableHeaders(req.headers, resolution.resolved),
475475
redirect: "manual",
476476
signal: controller.signal,
@@ -1030,7 +1030,7 @@ const landingCard = (req, landing) => {
10301030
${csrfInput(req)}
10311031
<input type="hidden" name="label" value="${esc(landing.label)}">
10321032
<span class="mono acid">${esc(landing.name)}</span>
1033-
<input name="target" placeholder="points at… (optional)" autocomplete="off">
1033+
<input name="target" placeholder="points at… (IPv6 or hostname, optional)" autocomplete="off">
10341034
<button class="btn acid" type="submit">Register it</button>
10351035
</form>
10361036
</div>`;
@@ -1332,6 +1332,51 @@ const pager = ({ page, total, perPage, href }) => {
13321332
</nav>`;
13331333
};
13341334

1335+
/**
1336+
* What to put in "points at", and what has to be listening at the other end.
1337+
*
1338+
* This sits next to the field rather than on a docs page because the field is
1339+
* a bare text input whose one hint is a placeholder, and the thing it wants —
1340+
* an address that a web server is already virtual-hosting the name on — is not
1341+
* guessable from "points at…". Collapsed by default: the answer is three lines
1342+
* once you know it, and this list is only in the way afterwards.
1343+
*/
1344+
function hostingHelp() {
1345+
return `<details class="pit-bulk">
1346+
<summary>how do I host a site at a name I hold?</summary>
1347+
<ol class="pit-steps dim">
1348+
<li><strong>Put your server's IPv6 address in "points at".</strong> Just the address —
1349+
<code>2606:4700:4700::1111</code>. IPv4 literals are refused: an A record on a small host
1350+
is usually leased or NATed and goes stale without telling anyone. A hostname
1351+
(<code>box.example.com</code>) works too.</li>
1352+
<li><strong>Turn the resolver on, on any machine that should reach the name:</strong>
1353+
<code>sudo moshcode dns enable</code>. Moshpit endings are not in the public DNS root, so
1354+
nothing resolves them until this is running — it answers <code>AAAA</code> for your names
1355+
out of the registry and leaves every other lookup alone.</li>
1356+
<li><strong>Serve the name on that address, port 80.</strong> The browser connects straight to
1357+
your box and sends <code>Host: name.ending</code> — nothing proxies, nothing redirects, so
1358+
your web server needs a block that answers to the name.</li>
1359+
</ol>
1360+
<p class="pit-copy" style="font-size:.84rem">Caddy — the <code>http://</code> is required, not a
1361+
typo: no public CA will issue a certificate for an ending that is not in the DNS root, so
1362+
automatic HTTPS has to stay off.</p>
1363+
<div class="pit-pre">http://seo.rank {
1364+
root * /var/www/seo.rank
1365+
file_server
1366+
}</div>
1367+
<p class="pit-copy" style="font-size:.84rem">nginx:</p>
1368+
<div class="pit-pre">server {
1369+
listen [::]:80;
1370+
server_name seo.rank;
1371+
root /var/www/seo.rank;
1372+
}</div>
1373+
<p class="pit-copy" style="font-size:.84rem">Check it with <code>moshcode dns resolve seo.rank</code>,
1374+
then <code>curl -6 http://seo.rank/</code>. A port other than 80 only works in the
1375+
<code>/n/</code> view — DNS carries an address and has nowhere to put a port, so the browser
1376+
goes to 80 whatever the target says.</p>
1377+
</details>`;
1378+
}
1379+
13351380
moshpitRouter.get("/pit", async (req, res) => {
13361381
// An unknown ?tab= falls back to Yours rather than rendering an empty page.
13371382
const tab = req.query.tab === "theirs" ? "theirs" : "yours";
@@ -1424,7 +1469,7 @@ moshpitRouter.get("/pit", async (req, res) => {
14241469
${csrfInput(req)}
14251470
<input type="hidden" name="label" value="${esc(n.label)}">
14261471
<span class="mono acid">${esc(n.label)}.${esc(t.tld)}</span>
1427-
<input name="target" placeholder="points at…" value="${esc(n.target || "")}" autocomplete="off">
1472+
<input name="target" placeholder="points at… (IPv6 or hostname)" value="${esc(n.target || "")}" autocomplete="off">
14281473
<button class="btn" type="submit" name="retarget" value="1">Save</button>
14291474
<button class="btn" type="submit" name="release" value="1">Release</button>
14301475
</form>`).join("")
@@ -1439,7 +1484,7 @@ moshpitRouter.get("/pit", async (req, res) => {
14391484
${csrfInput(req)}
14401485
<input name="label" placeholder="new name" autocomplete="off" required>
14411486
<span class="mono faint">.${esc(t.tld)}</span>
1442-
<input name="target" placeholder="points at… (optional)" autocomplete="off">
1487+
<input name="target" placeholder="points at… (IPv6 or hostname, optional)" autocomplete="off">
14431488
<button class="btn acid" type="submit">Add name</button>
14441489
</form>
14451490
</div>
@@ -1526,6 +1571,7 @@ moshpitRouter.get("/pit", async (req, res) => {
15261571
Endings you hold. Names under them are yours to mint for nothing — or put a price on the
15271572
ending and let anyone buy one.
15281573
</p>`}
1574+
${hostingHelp()}
15291575
${mineHtml}
15301576
${focused ? "" : pager({
15311577
page: pageNo, total: mineTotal, perPage: TLDS_PER_PAGE,

apps/pwa/test/moshpit-api-key.test.mjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,19 @@ test("api key: names can be minted and pointed from a script", skip, async () =>
142142
const { one } = await app();
143143
assert.equal((await one("POST", "/api/moshpit/tlds/held/names", { label: "blue" })).status, 201);
144144

145-
const pointed = await one("PUT", "/api/moshpit/tlds/held/names", { label: "blue", target: "203.0.113.7" });
145+
const pointed = await one("PUT", "/api/moshpit/tlds/held/names", {
146+
label: "blue", target: "2606:4700:4700::1111",
147+
});
146148
assert.equal(pointed.status, 200, pointed.text);
147149

148150
const resolved = await one("GET", "/api/moshpit/resolve?name=blue.held");
149-
assert.equal(resolved.json.target, "203.0.113.7");
151+
assert.equal(resolved.json.target, "2606:4700:4700::1111");
152+
153+
// The IPv6 rule holds for scripts too, or the API becomes the way around it.
154+
const v4 = await one("PUT", "/api/moshpit/tlds/held/names", { label: "blue", target: "198.51.100.7" });
155+
assert.equal(v4.status, 400, v4.text);
156+
const still = await one("GET", "/api/moshpit/resolve?name=blue.held");
157+
assert.equal(still.json.target, "2606:4700:4700::1111", "a refused target left the old one alone");
150158
});
151159

152160
test("api key: the browser routes are still browser routes", skip, async () => {

apps/pwa/test/moshpit-gateway.test.mjs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import assert from "node:assert/strict";
88
import test from "node:test";
99

1010
import {
11-
blockedReason, checkTarget, forwardableHeaders, parseTarget,
11+
blockedReason, checkTarget, forwardableHeaders, normalizeTarget, originUrl, parseTarget, urlHost,
1212
} from "../src/lib/moshpit-gateway.mjs";
1313

1414
test("addresses that must never be fetched", () => {
@@ -92,7 +92,13 @@ test("one bad address among good ones fails the whole target", async () => {
9292
test("a public hostname passes and reports where it went", async () => {
9393
const resolve = async () => [{ address: "93.184.216.34" }];
9494
const result = await checkTarget("example.com:8080", { resolve });
95-
assert.deepEqual(result, { ok: true, host: "example.com", port: 8080, addresses: ["93.184.216.34"] });
95+
assert.deepEqual(result, {
96+
ok: true,
97+
host: "example.com",
98+
port: 8080,
99+
origin: "http://example.com:8080",
100+
addresses: ["93.184.216.34"],
101+
});
96102
});
97103

98104
test("a name that does not resolve is refused", async () => {
@@ -122,3 +128,53 @@ test("credentials are never forwarded to an origin", () => {
122128
assert.equal(headers.host, "blue.eggs");
123129
assert.equal(headers["x-moshpit-name"], "blue.eggs");
124130
});
131+
132+
/* --------------------------------------------------------- what may be stored */
133+
134+
test("an IPv6 target survives the round trip into a fetchable URL", async () => {
135+
// The bug this pins: `http://2606:4700::1111:80/` is not a URL. Unbracketed,
136+
// the address's own colons are indistinguishable from the port separator, so
137+
// fetch rejected it and every IPv6 name 504'd with "could not be reached".
138+
const stored = normalizeTarget("2606:4700:4700::1111");
139+
assert.equal(stored.target, "2606:4700:4700::1111");
140+
141+
const result = await checkTarget(stored.target);
142+
assert.equal(result.ok, true);
143+
assert.equal(result.origin, "http://[2606:4700:4700::1111]:80");
144+
assert.doesNotThrow(() => new URL(`${result.origin}/`));
145+
assert.equal(urlHost("203.0.114.9"), "203.0.114.9");
146+
assert.equal(originUrl({ host: "box.example.com", port: 8080 }), "http://box.example.com:8080");
147+
});
148+
149+
test("IPv4 literals are refused, and the message says what to use instead", () => {
150+
const result = normalizeTarget("203.0.114.9");
151+
assert.equal(result.ok, false);
152+
assert.match(result.error, /IPv6/);
153+
});
154+
155+
test("empty is not an error — a name may wait to be pointed", () => {
156+
for (const empty of ["", " ", null, undefined]) {
157+
assert.deepEqual(normalizeTarget(empty), { ok: true, target: null });
158+
}
159+
});
160+
161+
test("a port forces brackets, and only then", () => {
162+
assert.equal(normalizeTarget("2606:4700::1111").target, "2606:4700::1111");
163+
assert.equal(normalizeTarget("[2606:4700::1111]:8080").target, "[2606:4700::1111]:8080");
164+
assert.equal(normalizeTarget("http://[2606:4700::1111]:8080/").target, "[2606:4700::1111]:8080");
165+
});
166+
167+
test("hostnames stay allowed, lowercased, scheme stripped", () => {
168+
assert.equal(normalizeTarget("https://Box.Example.COM/").target, "box.example.com");
169+
assert.equal(normalizeTarget("box.example.com:8443").target, "box.example.com:8443");
170+
assert.equal(normalizeTarget("not a hostname").ok, false);
171+
// A bare label has no dot, so it cannot be a public name.
172+
assert.equal(normalizeTarget("localhost").ok, false);
173+
});
174+
175+
test("an unroutable IPv6 address is refused at the form, not at fetch time", () => {
176+
// Storing it would mint a name that looks live and 502s for every visitor.
177+
for (const addr of ["::1", "fe80::1", "fd00::1", "ff02::1"]) {
178+
assert.equal(normalizeTarget(addr).ok, false, addr);
179+
}
180+
});

apps/pwa/test/moshpit-registry.test.mjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ test("moshpit registry", { skip: installed ? false : "pwa dependencies not insta
144144
const r = await m.registerName({ tld: "eggs", label: "Blue", userId: ALICE, target: "https://example.com" });
145145
assert.equal(r.ok, true);
146146
assert.equal(r.name.label, "blue");
147-
assert.equal(r.name.target, "https://example.com");
147+
assert.equal(r.name.target, "example.com");
148148
assert.deepEqual((await m.listNames("eggs")).map((n) => n.label), ["blue"]);
149149
});
150150

@@ -178,7 +178,7 @@ test("moshpit registry", { skip: installed ? false : "pwa dependencies not insta
178178
const r = await m.resolveMoshpitName("blue.eggs");
179179
assert.equal(r.registered, true, "the TLD is claimed");
180180
assert.equal(r.name_registered, true);
181-
assert.equal(r.target, "https://example.com");
181+
assert.equal(r.target, "example.com");
182182
});
183183

184184
await t.test("an unminted name under a claimed TLD is not registered", async () => {
@@ -196,7 +196,7 @@ test("moshpit registry", { skip: installed ? false : "pwa dependencies not insta
196196
const viaAlias = await m.resolveMoshpitName("foo.agentic");
197197
assert.equal(viaAlias.resolved, "foo.agents");
198198
assert.equal(viaAlias.name_registered, true);
199-
assert.equal(viaAlias.target, "https://foo.example");
199+
assert.equal(viaAlias.target, "foo.example");
200200
await m.clearAlias("agentic", ALICE);
201201
});
202202

@@ -205,7 +205,7 @@ test("moshpit registry", { skip: installed ? false : "pwa dependencies not insta
205205
assert.equal((await m.releaseName({ tld: "eggs", label: "blue", userId: BOB })).ok, false);
206206

207207
assert.equal((await m.setNameTarget({ tld: "eggs", label: "blue", userId: ALICE, target: "https://new.example" })).ok, true);
208-
assert.equal((await m.getName("eggs", "blue")).target, "https://new.example");
208+
assert.equal((await m.getName("eggs", "blue")).target, "new.example");
209209

210210
assert.equal((await m.releaseName({ tld: "eggs", label: "blue", userId: ALICE })).ok, true);
211211
assert.equal(await m.getName("eggs", "blue"), null);
@@ -215,10 +215,10 @@ test("moshpit registry", { skip: installed ? false : "pwa dependencies not insta
215215
// A TLD may not be all-numeric (ambiguous against an IPv4 literal), but a
216216
// label under one carries no such ambiguity -- 123.eggs is a fine name.
217217
assert.equal((await m.registerName({ tld: "eggs", label: "123", userId: ALICE, target: "https://n.example" })).ok, true);
218-
assert.equal((await m.getName("eggs", "123")).target, "https://n.example");
218+
assert.equal((await m.getName("eggs", "123")).target, "n.example");
219219

220220
assert.equal((await m.setNameTarget({ tld: "eggs", label: "123", userId: ALICE, target: "https://n2.example" })).ok, true);
221-
assert.equal((await m.getName("eggs", "123")).target, "https://n2.example");
221+
assert.equal((await m.getName("eggs", "123")).target, "n2.example");
222222

223223
assert.equal((await m.resolveMoshpitName("123.eggs")).name_registered, true);
224224

0 commit comments

Comments
 (0)