clone

nostr relays on demand at <name>.bind.ws: claim one with your key, pay in zaps past the free allowance

manage: adddomain, checkdomain, removedomain and listdomains

Dami committed a64f098
Identity action. A hostname is validated, refused when another relay holds
it in KV, capped at three per relay, and mapped only after Cloudflare
accepted it. The answers carry the CNAME to create and the optional TXT
pre-validation record. Tests drive the Cloudflare API through a fake fetch.
commit
a64f098a2fc91f1255119b06b413f2e6e5a2661f
parent
9c9cacdc9ff6ecae88e1d7381d36f4ccda86bf0e
author
Dami <dami.etoile@gmail.com>
3 files changed, +190 -0
Msrc/manage.ts23++++
Msrc/roles.ts1+
Atest/domains.test.ts166++++++++++++++++++++++++++++++
M src/manage.ts +23 -0
@@ -14,6 +14,7 @@ import { notify, notifySettings } from "./notify.ts";
1414 import { DUMP_NAME_RE, deleteDump, dumpBytes, listDumps, writeDump } from "./dumps.ts";1515 import { can, METHOD_ACTIONS } from "./roles.ts";1616 import { PRESETS, applyPreset, findPreset } from "./presets.ts";17+import { addDomain, checkDomain, listDomains, removeDomain } from "./domains.ts";1718 1819 // verifyNIP98 checks an "Authorization: Nostr <base64 event>" header against1920 // the request: kind 27235, fresh, u tag naming this URL, method tag, and a
@@ -64,6 +65,7 @@ const METHODS = [
6465 "removesubtree",6566 "forkrelay",6667 "setsuccession", "clearsuccession", "successionstatus",68+ "adddomain", "removedomain", "checkdomain", "listdomains",6769 ];6870 6971 // validIP accepts an IPv4 or IPv6 address, nothing fancier: no ranges, no names.
@@ -519,6 +521,27 @@ export async function manage(relay: Relay, req: Request): Promise<Response> {
519521 s.update({ icon: str(0).slice(0, 2000) });520522 await relay.publishMembership();521523 return reply({ result: true });524+ // Custom domains: the relay under a hostname the owner controls.525+ case "adddomain": {526+ const r = await addDomain(relay, str(0));527+ if (typeof r === "string") return reply({ error: r }, r.startsWith("error:") ? 502 : 400);528+ return reply({ result: r });529+ }530+ case "checkdomain": {531+ const r = await checkDomain(relay, str(0));532+ if (typeof r === "string") return reply({ error: r }, r.startsWith("error:") ? 502 : 400);533+ return reply({ result: r });534+ }535+ case "removedomain": {536+ const err = await removeDomain(relay, str(0));537+ if (err) return reply({ error: err }, err.startsWith("error:") ? 502 : 400);538+ return reply({ result: true });539+ }540+ case "listdomains": {541+ const r = listDomains(relay);542+ if (typeof r === "string") return reply({ error: r }, 400);543+ return reply({ result: r });544+ }522545 }523546 return reply({ error: "unsupported: " + method }, 400);524547 }
M src/roles.ts +1 -0
A test/domains.test.ts +166 -0
@@ -0,0 +1,166 @@
1+// Custom domains: a hostname the owner controls, mapped to the relay through2+// KV and registered with Cloudflare for SaaS. The Cloudflare API is a fake3+// fetch here; routing goes through the real worker and the KV binding.4+import { SELF, env, runInDurableObject } from "cloudflare:test";5+import { describe, it, expect } from "vitest";6+import { finalizeEvent, generateSecretKey } from "nostr-tools/pure";7+import { getToken } from "nostr-tools/nip98";8+import type { Relay } from "../src/relay.ts";9+import { Hostnames, checkHostname, MAX_CUSTOM_HOSTS } from "../src/domains.ts";10+11+async function rpc(host: string, sk: Uint8Array, method: string, ...params: unknown[]) {12+ const url = `http://${host}/`;13+ const payload = { method, params };14+ const token = await getToken(url, "POST", (e) => finalizeEvent(e, sk), true, payload);15+ const resp = await SELF.fetch(url, { method: "POST", headers: { "content-type": "application/nostr+json+rpc", authorization: token }, body: JSON.stringify(payload) });16+ return { status: resp.status, ...(await resp.json<any>()) };17+}18+19+const info = async (host: string) => (await SELF.fetch(`http://${host}/`, { headers: { accept: "application/nostr+json" } })).json<any>();20+21+// fakeCloudflare answers the custom hostnames API and records every call.22+function fakeCloudflare(opts: { active?: boolean; refuse?: boolean } = {}) {23+ const calls: { method: string; url: string; body: any }[] = [];24+ let n = 0;25+ const json = (b: unknown, status = 200) => new Response(JSON.stringify(b), { status, headers: { "content-type": "application/json" } });26+ const fetcher = async (url: string, init?: RequestInit) => {27+ const method = init?.method ?? "GET";28+ const body = init?.body ? JSON.parse(String(init.body)) : null;29+ calls.push({ method, url, body });30+ if (!url.startsWith("https://api.cloudflare.com/client/v4/zones/zone-1/custom_hostnames")) return json({ success: false, errors: [{ code: 7003, message: "no such route" }] }, 404);31+ if ((init?.headers as Record<string, string>)?.authorization !== "Bearer token") return json({ success: false, errors: [{ code: 10000, message: "Authentication error" }] }, 403);32+ if (opts.refuse) return json({ success: false, errors: [{ code: 1406, message: "Invalid custom hostname" }] }, 400);33+ if (method === "POST") {34+ n++;35+ return json({36+ success: true,37+ result: {38+ id: "ch-" + n,39+ hostname: body.hostname,40+ status: "pending",41+ ssl: { status: "pending_validation", method: body.ssl.method, type: body.ssl.type, validation_records: [{ http_url: `http://${body.hostname}/.well-known/pki-validation/x.txt`, http_body: "dcv-token" }] },42+ ownership_verification: { type: "txt", name: "_cf-custom-hostname." + body.hostname, value: "ov-" + n },43+ },44+ });45+ }46+ const id = url.split("/").pop();47+ if (method === "GET") {48+ return json({ success: true, result: { id, hostname: "x", status: opts.active ? "active" : "pending", ssl: { status: opts.active ? "active" : "pending_validation" }, ownership_verification: { type: "txt", name: "_cf-custom-hostname.x", value: "ov" } } });49+ }50+ if (method === "DELETE") return json({ success: true, result: { id } });51+ return json({ success: false, errors: [{ message: "unexpected" }] }, 400);52+ };53+ return { calls, fetcher, opts };54+}55+56+// enable gives a relay the Cloudflare client, backed by the fake.57+async function enable(name: string, fake: ReturnType<typeof fakeCloudflare>) {58+ await runInDurableObject(env.RELAY.getByName(name), async (r: Relay) => {59+ r.hostnames = new Hostnames("token", "zone-1", fake.fetcher);60+ });61+}62+63+describe("custom domains", () => {64+ it("routes a custom hostname to its relay through KV and prints URLs with it", async () => {65+ const owner = generateSecretKey();66+ await rpc("alice.bind.ws", owner, "claim");67+ await env.HOSTS.put("relay.alice.test", "alice");68+ const doc = await info("relay.alice.test");69+ expect(doc.name).toBe("alice");70+ expect(doc.self_url).toBe("wss://relay.alice.test");71+ expect(doc.pubkey).toBe((await info("alice.bind.ws")).pubkey);72+ // An unknown host falls back to the dev relay, as before.73+ expect((await info("nowhere.test")).name).toBe("dev");74+ });75+76+ it("checks hostnames strictly", () => {77+ expect(checkHostname(" Relay.Example.com. ", "bind.ws")).toEqual({ host: "relay.example.com" });78+ for (const bad of ["bind.ws", "x.bind.ws", "localhost", "a.localhost", "printer.local", "1.2.3.4", "::1", "single", "a..b", "-bad.example.com", "bad-.example.com", "a.123", "x".repeat(64) + ".com", ""]) {79+ expect("error" in checkHostname(bad, "bind.ws"), bad).toBe(true);80+ }81+ });82+83+ it("is off without the token", async () => {84+ const owner = generateSecretKey();85+ await rpc("plain.bind.ws", owner, "claim");86+ const r = await rpc("plain.bind.ws", owner, "adddomain", "relay.plain.test");87+ expect(r.status).toBe(400);88+ expect(r.error).toMatch(/^unsupported:/);89+ expect((await rpc("plain.bind.ws", owner, "listdomains")).error).toMatch(/^unsupported:/);90+ expect(await env.HOSTS.get("relay.plain.test")).toBeNull();91+ });92+93+ it("adds, checks and removes a domain, mapping it only after Cloudflare accepted", async () => {94+ const host = "bob.bind.ws";95+ const owner = generateSecretKey();96+ await rpc(host, owner, "claim");97+ const fake = fakeCloudflare();98+ await enable("bob", fake);99+100+ // Refusals before any API call: bad names, a name another relay holds.101+ expect((await rpc(host, owner, "adddomain", "x.bind.ws")).error).toMatch(/^invalid:/);102+ await env.HOSTS.put("relay.carol.test", "carol");103+ expect((await rpc(host, owner, "adddomain", "relay.carol.test")).error).toMatch(/another relay/);104+ expect(fake.calls.length).toBe(0);105+ // A member may not.106+ expect((await rpc(host, generateSecretKey(), "adddomain", "relay.bob.test")).status).toBe(403);107+108+ const added = await rpc(host, owner, "adddomain", "Relay.Bob.Test");109+ expect(added.status, JSON.stringify(added)).toBe(200);110+ expect(added.result.host).toBe("relay.bob.test");111+ expect(added.result.ready).toBe(false);112+ expect(added.result.records[0]).toMatchObject({ type: "CNAME", name: "relay.bob.test", value: "customers.bind.ws" });113+ expect(added.result.records[1]).toMatchObject({ type: "TXT", name: "_cf-custom-hostname.relay.bob.test", value: "ov-1" });114+ expect(fake.calls[0]).toMatchObject({ method: "POST", body: { hostname: "relay.bob.test", ssl: { method: "http", type: "dv" } } });115+ expect(await env.HOSTS.get("relay.bob.test")).toBe("bob");116+ expect((await rpc(host, owner, "adddomain", "relay.bob.test")).error).toMatch(/^duplicate:/);117+118+ const list = (await rpc(host, owner, "listdomains")).result;119+ expect(list.map((d: any) => d.host)).toEqual(["relay.bob.test"]);120+ // The export never carries domains: they belong to this relay instance.121+ expect((await rpc(host, owner, "exportconfig")).result.policy.customHosts).toBeUndefined();122+123+ fake.opts.active = true;124+ const checked = await rpc(host, owner, "checkdomain", "relay.bob.test");125+ expect(checked.result.ready).toBe(true);126+ expect(checked.result.records.length).toBe(1);127+ expect(fake.calls.at(-1)).toMatchObject({ method: "GET", url: expect.stringMatching(/\/custom_hostnames\/ch-1$/) });128+ expect((await rpc(host, owner, "listdomains")).result[0].status).toBe("active");129+130+ for (let i = 1; i < MAX_CUSTOM_HOSTS; i++) expect((await rpc(host, owner, "adddomain", `r${i}.bob.test`)).status).toBe(200);131+ expect((await rpc(host, owner, "adddomain", "one-too-many.bob.test")).error).toMatch(/^restricted: at most/);132+133+ expect((await rpc(host, owner, "removedomain", "relay.bob.test")).result).toBe(true);134+ expect(fake.calls.at(-1)).toMatchObject({ method: "DELETE", url: expect.stringMatching(/\/custom_hostnames\/ch-1$/) });135+ expect(await env.HOSTS.get("relay.bob.test")).toBeNull();136+ expect((await rpc(host, owner, "listdomains")).result.length).toBe(MAX_CUSTOM_HOSTS - 1);137+ expect((await rpc(host, owner, "removedomain", "relay.bob.test")).error).toMatch(/^invalid:/);138+ // The other relay's mapping was never touched.139+ expect(await env.HOSTS.get("relay.carol.test")).toBe("carol");140+ });141+142+ it("writes nothing when Cloudflare refuses", async () => {143+ const host = "dan.bind.ws";144+ const owner = generateSecretKey();145+ await rpc(host, owner, "claim");146+ await enable("dan", fakeCloudflare({ refuse: true }));147+ const r = await rpc(host, owner, "adddomain", "relay.dan.test");148+ expect(r.status).toBe(502);149+ expect(r.error).toMatch(/^error: Cloudflare: Invalid custom hostname/);150+ expect(await env.HOSTS.get("relay.dan.test")).toBeNull();151+ expect((await rpc(host, owner, "listdomains")).result).toEqual([]);152+ });153+154+ it("teardown removes the domains from Cloudflare and KV", async () => {155+ const host = "gonehost.bind.ws";156+ const owner = generateSecretKey();157+ await rpc(host, owner, "claim");158+ const fake = fakeCloudflare();159+ await enable("gonehost", fake);160+ expect((await rpc(host, owner, "adddomain", "relay.gone.test")).status).toBe(200);161+ expect(await env.HOSTS.get("relay.gone.test")).toBe("gonehost");162+ expect((await rpc(host, owner, "deleterelay", "gonehost")).result).toEqual({ deleted: true, name: "gonehost" });163+ expect(fake.calls.some((c) => c.method === "DELETE" && c.url.endsWith("/ch-1"))).toBe(true);164+ expect(await env.HOSTS.get("relay.gone.test")).toBeNull();165+ });166+});