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
10 changes: 10 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { configFor, safeDomain } from "@/lib/config";
import { isMoshpitName } from "@/lib/moshpit-tlds";
import { pitNameUrl } from "@/lib/parking";
import { getTenantConfig } from "@/lib/db";
import Landing from "@/components/Landing";
import Tenant from "@/components/Tenant";
Expand Down Expand Up @@ -69,6 +72,13 @@ export default async function Page({
const dn = safeDomain(sp.dn) || (await hostTenantDn());
if (!dn) return <Landing />;

// A Moshpit name that DNS parked here belongs to the Pit, not to this app's
// parked-domain card. Temporary, not permanent: the owner can point the name
// at a real target at any moment, and a 308 cached in a browser would keep
// sending them here long after this app stopped being the answer. Ordinary
// parked clearnet domains do not match and render exactly as before.
if (await isMoshpitName(dn)) redirect(pitNameUrl(dn));

// A paid/provisioned domain has a tenants row that overrides the defaults.
const tenantOverride = await getTenantConfig(dn).catch(() => null);

Expand Down
63 changes: 63 additions & 0 deletions lib/moshpit-tlds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { PIT_BASE_URL } from "./parking";

/**
* Is this host a name on the Moshpit network, or an ordinary parked domain?
*
* Asked of the Pit per name rather than by pulling its ending list: that list
* is capped (200 at the time of writing) and `.eggs` already falls outside it,
* so matching against it would quietly answer "no" for real names. `resolve`
* has no such ceiling — its `registered` flag is exactly "an ending the pit
* holds", which is the question here.
*
* This app keeps its own `moshpit_tlds` rows, but they are a cache of a
* registry that lives elsewhere; a name claimed minutes ago has to work.
*/

/** Long enough not to hit the Pit per request, short enough for a new ending to show up. */
const TTL_MS = 5 * 60_000;
const TIMEOUT_MS = 2_000;
/** Host is attacker-controlled, so the memo cannot be allowed to grow without end. */
const MAX_ENTRIES = 2_000;

const cache = new Map<string, { at: number; ours: boolean }>();

function remember(name: string, ours: boolean): void {
// Whole-map eviction rather than LRU bookkeeping: this is a small memo in
// front of a 5-minute TTL, and the cost of a cold start is one request.
if (cache.size >= MAX_ENTRIES) cache.clear();
cache.set(name, { at: Date.now(), ours });
}

/**
* Fails closed: an unreachable Pit answers "no", so a registry outage leaves
* every parked domain rendering exactly as it does today rather than bouncing
* the whole internet at `/n/`. A previously cached answer beats that fallback.
*/
export async function isMoshpitName(dn: string): Promise<boolean> {
const name = String(dn || "").toLowerCase();
const parts = name.split(".");
// One label and one ending — the same shape the registry can hold.
if (parts.length !== 2 || !parts[0] || !parts[1]) return false;

const hit = cache.get(name);
if (hit && Date.now() - hit.at < TTL_MS) return hit.ours;

try {
const res = await fetch(
`${PIT_BASE_URL}/api/moshpit/resolve?name=${encodeURIComponent(name)}`,
{ signal: AbortSignal.timeout(TIMEOUT_MS), cache: "no-store" },
);
if (!res.ok) return hit?.ours ?? false;
const json = (await res.json()) as { registered?: boolean };
const ours = json?.registered === true;
remember(name, ours);
return ours;
} catch {
return hit?.ours ?? false;
}
}

/** Test seam: drop the memoised answers. */
export function resetMoshpitTldCache(): void {
cache.clear();
}
5 changes: 5 additions & 0 deletions lib/parking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,10 @@ export function parkingTarget(sp: ParkingParams): string {
// Nothing usable to look up — the Pit's front door beats a 404 for someone
// who just typed a name at us.
if (!name) return `${PIT_BASE_URL}/pit`;
return pitNameUrl(name);
}

/** The Pit's page for a name. Callers pass something safeDomain() has cleared. */
export function pitNameUrl(name: string): string {
return `${PIT_BASE_URL}/n/${encodeURIComponent(name)}`;
}
110 changes: 110 additions & 0 deletions tests/moshpit-tlds.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import test from "node:test";

import { isMoshpitName, resetMoshpitTldCache } from "../lib/moshpit-tlds.ts";

const realFetch = globalThis.fetch;

/** Stand in for the Pit's resolve endpoint. */
function pitSays(registeredEndings, { ok = true } = {}) {
const seen = [];
globalThis.fetch = async (url) => {
seen.push(String(url));
const name = new URL(String(url)).searchParams.get("name") || "";
const ending = name.split(".").pop();
return { ok, json: async () => ({ name, registered: registeredEndings.includes(ending) }) };
};
return seen;
}

function pitIsDown() {
globalThis.fetch = async () => {
throw new Error("registry unreachable");
};
}

test.afterEach(() => {
globalThis.fetch = realFetch;
resetMoshpitTldCache();
});

test("a name under a Moshpit ending is ours", async () => {
resetMoshpitTldCache();
pitSays(["eggs", "chicken"]);

assert.equal(await isMoshpitName("scrambled.eggs"), true);
assert.equal(await isMoshpitName("hawaiian.chicken"), true);
});

test("an ordinary parked domain is not ours, and keeps its tenant page", async () => {
resetMoshpitTldCache();
pitSays(["eggs"]);

// `.sh` is a real TLD nobody claimed in the pit — it must not be hijacked.
assert.equal(await isMoshpitName("moshcode.sh"), false);
assert.equal(await isMoshpitName("example.com"), false);
});

test("only one label and one ending counts, and it never calls out for the rest", async () => {
resetMoshpitTldCache();
const seen = pitSays(["eggs"]);

assert.equal(await isMoshpitName("a.b.eggs"), false);
assert.equal(await isMoshpitName("eggs"), false);
assert.equal(await isMoshpitName(""), false);
assert.equal(seen.length, 0, "a shape the registry cannot hold needs no request");
});

test("the answer is memoised per name rather than fetched per request", async () => {
resetMoshpitTldCache();
const seen = pitSays(["eggs"]);

await isMoshpitName("scrambled.eggs");
await isMoshpitName("scrambled.eggs");
await isMoshpitName("scrambled.eggs");

assert.equal(seen.length, 1, "the Pit should be hit once per name");
});

test("the name is normalized, so case is not a cache miss", async () => {
resetMoshpitTldCache();
const seen = pitSays(["eggs"]);

assert.equal(await isMoshpitName("Scrambled.EGGS"), true);
assert.equal(await isMoshpitName("scrambled.eggs"), true);
assert.equal(seen.length, 1);
});

test("an unreachable Pit fails closed rather than bouncing everything", async () => {
resetMoshpitTldCache();
pitIsDown();

// The whole internet parked here must not start redirecting to /n/ because
// the registry blipped — today's behaviour is the safe answer.
assert.equal(await isMoshpitName("scrambled.eggs"), false);
});

test("a non-200 from the Pit is treated as unreachable", async () => {
resetMoshpitTldCache();
pitSays(["eggs"], { ok: false });

assert.equal(await isMoshpitName("scrambled.eggs"), false);
});

test("a cached answer beats the fail-closed fallback when the Pit goes down", async () => {
resetMoshpitTldCache();
pitSays(["eggs"]);
assert.equal(await isMoshpitName("scrambled.eggs"), true);

pitIsDown();
assert.equal(await isMoshpitName("scrambled.eggs"), true, "warm cache should hold");
});

test("the lookup asks the Pit for the exact name", async () => {
resetMoshpitTldCache();
const seen = pitSays(["eggs"]);

await isMoshpitName("scrambled.eggs");

assert.match(seen[0], /\/api\/moshpit\/resolve\?name=scrambled\.eggs$/);
});
Loading