Cloudflare for SaaS custom hostnames: the customer's hostname enters the zone, a catch-all route sends it to the Worker, and the Worker looks the hostname up in the HOSTS KV namespace to find the relay name. The object keeps its hostnames in policy and talks to the custom hostnames API with CF_API_TOKEN and ZONE_ID; without the token the feature is off. Certificates use HTTP validation, so an owner creates one CNAME. Relay URLs are wss for anything but local development now, custom domains included. Teardown removes the hostnames from Cloudflare and KV.
- commit
- 9c9cacdc9ff6ecae88e1d7381d36f4ccda86bf0e
- parent
- 9f9102bbb35e55a0d78407e2bb054f2899935ba0
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 214 | ++++++++++++++++++++++++++++++ |
| M | src/ | 21 | +++- |
| M | src/ | 28 | ++++- |
| M | src/ | 5 | +- |
| M | test/ | 6 | + |
| M | wrangler.jsonc | 17 | ++- |
@@ -0,0 +1,214 @@
1+// Custom domains: relay.alice.com as a CNAME onto her relay, through2+// Cloudflare for SaaS custom hostnames. The worker maps a hostname to a3+// relay name through the HOSTS KV namespace (see index.ts); the object keeps4+// its hostnames in policy and talks to the Cloudflare API to create, check5+// and remove them. Requests on a custom hostname arrive with the same6+// x-relay-name header as any other, so nothing inside the object cares7+// which door they came through except the URLs it prints, which already8+// follow the request host.9+import type { Fetcher } from "./fuel.ts";10+import type { Relay } from "./relay.ts";11+12+export interface CustomHost {13+ host: string;14+ id: string; // Cloudflare's custom hostname id15+ at: number; // when last checked16+ status: string; // Cloudflare's hostname status, active when it proxies17+ sslStatus: string; // certificate status, active when issued18+}19+20+export const MAX_CUSTOM_HOSTS = 3;21+export const UNSUPPORTED = "unsupported: custom domains are not enabled on this host";22+23+const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;24+const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;25+26+// checkHostname normalises and validates a hostname. Returns the hostname27+// or a reason: lowercase labels, two or more of them, not the service28+// domain or anything under it, not local, not an address.29+export function checkHostname(raw: string, domain: string): { host: string } | { error: string } {30+ const host = raw.trim().toLowerCase().replace(/\.$/, "");31+ const d = domain.toLowerCase();32+ if (host.length === 0 || host.length > 253) return { error: "invalid: hostname length" };33+ if (host.includes(":") || IPV4.test(host)) return { error: "invalid: an address is not a hostname" };34+ const labels = host.split(".");35+ if (labels.length < 2 || labels.some((l) => !LABEL.test(l))) return { error: "invalid: not a valid hostname" };36+ if (/^\d+$/.test(labels[labels.length - 1])) return { error: "invalid: not a valid hostname" };37+ if (host === d || host.endsWith("." + d)) return { error: "invalid: names under " + d + " are relays already" };38+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return { error: "invalid: that name is local" };39+ return { host };40+}41+42+// What Cloudflare tells us about a custom hostname, trimmed to what we show.43+export interface HostnameState {44+ id: string;45+ hostname: string;46+ status: string;47+ sslStatus: string;48+ ownership: { name: string; value: string } | null; // TXT pre-validation record49+ dcv: { txtName?: string; txtValue?: string; httpUrl?: string; httpBody?: string }[];50+}51+52+// Hostnames is the Cloudflare API client for the zone's custom hostnames.53+// Certificates use HTTP validation, which Cloudflare completes on its own54+// once the customer's CNAME resolves to us, so the owner has one record to55+// create. The token needs "SSL and Certificates: Edit" on the zone.56+export class Hostnames {57+ constructor(private token: string, private zone: string, private fetcher: Fetcher) {}58+59+ private async call(method: string, path: string, body?: unknown): Promise<Record<string, unknown> | null> {60+ const resp = await this.fetcher(`https://api.cloudflare.com/client/v4/zones/${this.zone}/custom_hostnames${path}`, {61+ method,62+ headers: { authorization: "Bearer " + this.token, "content-type": "application/json" },63+ body: body === undefined ? undefined : JSON.stringify(body),64+ });65+ let json: { success?: boolean; result?: Record<string, unknown> | null; errors?: { code?: number; message?: string }[] } = {};66+ try {67+ json = await resp.json();68+ } catch {69+ throw new Error(`Cloudflare answered ${resp.status}`);70+ }71+ if (!json.success) {72+ const e = json.errors?.[0];73+ if (method === "DELETE" && (resp.status === 404 || e?.code === 1436)) return null;74+ throw new Error(e?.message ? `Cloudflare: ${e.message}` : `Cloudflare answered ${resp.status}`);75+ }76+ return json.result ?? null;77+ }78+79+ async create(hostname: string): Promise<HostnameState> {80+ const r = await this.call("POST", "", { hostname, ssl: { method: "http", type: "dv" } });81+ if (!r) throw new Error("Cloudflare returned no hostname");82+ return parseState(r);83+ }84+85+ async get(id: string): Promise<HostnameState | null> {86+ const r = await this.call("GET", "/" + id);87+ return r ? parseState(r) : null;88+ }89+90+ async remove(id: string): Promise<void> {91+ await this.call("DELETE", "/" + id);92+ }93+}94+95+function parseState(r: Record<string, unknown>): HostnameState {96+ const ssl = (r.ssl ?? {}) as Record<string, unknown>;97+ const ov = (r.ownership_verification ?? null) as { name?: string; value?: string } | null;98+ const recs = Array.isArray(ssl.validation_records) ? (ssl.validation_records as Record<string, string>[]) : [];99+ return {100+ id: String(r.id ?? ""),101+ hostname: String(r.hostname ?? ""),102+ status: String(r.status ?? "pending"),103+ sslStatus: String(ssl.status ?? "pending_validation"),104+ ownership: ov && ov.name && ov.value ? { name: ov.name, value: ov.value } : null,105+ dcv: recs.map((x) => ({ txtName: x.txt_name, txtValue: x.txt_value, httpUrl: x.http_url, httpBody: x.http_body })),106+ };107+}108+109+// DomainView is what the console and API callers get.110+export interface DomainView {111+ host: string;112+ status: string;113+ sslStatus: string;114+ ready: boolean;115+ checkedAt: number;116+ records: { type: string; name: string; value: string; note: string }[];117+}118+119+function view(h: CustomHost, target: string, state: HostnameState | null): DomainView {120+ const records: DomainView["records"] = [{ type: "CNAME", name: h.host, value: target, note: "required; the certificate follows once it resolves" }];121+ if (state?.ownership && state.status !== "active") records.push({ type: "TXT", name: state.ownership.name, value: state.ownership.value, note: "optional; activates the hostname before you switch the CNAME" });122+ return { host: h.host, status: h.status, sslStatus: h.sslStatus, ready: h.status === "active" && h.sslStatus === "active", checkedAt: h.at, records };123+}124+125+function stored(relay: Relay): CustomHost[] {126+ return relay.settings.policy.customHosts ?? [];127+}128+129+function save(relay: Relay, hosts: CustomHost[]) {130+ relay.settings.update({ customHosts: hosts });131+}132+133+// addDomain registers a hostname with Cloudflare, then maps it in KV and134+// remembers it. KV is written only after Cloudflare accepted the hostname.135+export async function addDomain(relay: Relay, raw: string): Promise<DomainView | string> {136+ const api = relay.hostnames;137+ if (!api) return UNSUPPORTED;138+ const c = checkHostname(raw, relay.domain);139+ if ("error" in c) return c.error;140+ const hosts = stored(relay);141+ if (hosts.some((h) => h.host === c.host)) return "duplicate: that hostname is already on this relay";142+ if (hosts.length >= MAX_CUSTOM_HOSTS) return `restricted: at most ${MAX_CUSTOM_HOSTS} custom domains per relay`;143+ const taken = await relay.hosts.get(c.host);144+ if (taken && taken !== relay.slug) return "restricted: that hostname points at another relay";145+ let state: HostnameState;146+ try {147+ state = await api.create(c.host);148+ } catch (err) {149+ return "error: " + (err instanceof Error ? err.message : String(err));150+ }151+ await relay.hosts.put(c.host, relay.slug);152+ const h: CustomHost = { host: c.host, id: state.id, at: Math.floor(Date.now() / 1000), status: state.status, sslStatus: state.sslStatus };153+ save(relay, [...hosts, h]);154+ return view(h, relay.cnameTarget, state);155+}156+157+// checkDomain asks Cloudflare where the hostname stands and remembers it.158+export async function checkDomain(relay: Relay, raw: string): Promise<DomainView | string> {159+ const api = relay.hostnames;160+ if (!api) return UNSUPPORTED;161+ const host = raw.trim().toLowerCase();162+ const hosts = stored(relay);163+ const h = hosts.find((x) => x.host === host);164+ if (!h) return "invalid: no such custom domain on this relay";165+ let state: HostnameState | null;166+ try {167+ state = await api.get(h.id);168+ } catch (err) {169+ return "error: " + (err instanceof Error ? err.message : String(err));170+ }171+ if (!state) return "error: Cloudflare no longer knows this hostname; remove it and add it again";172+ const fresh: CustomHost = { ...h, at: Math.floor(Date.now() / 1000), status: state.status, sslStatus: state.sslStatus };173+ save(relay, hosts.map((x) => (x.host === host ? fresh : x)));174+ return view(fresh, relay.cnameTarget, state);175+}176+177+// removeDomain deletes the hostname at Cloudflare, then the mapping.178+export async function removeDomain(relay: Relay, raw: string): Promise<string> {179+ const api = relay.hostnames;180+ if (!api) return UNSUPPORTED;181+ const host = raw.trim().toLowerCase();182+ const hosts = stored(relay);183+ const h = hosts.find((x) => x.host === host);184+ if (!h) return "invalid: no such custom domain on this relay";185+ try {186+ await api.remove(h.id);187+ } catch (err) {188+ return "error: " + (err instanceof Error ? err.message : String(err));189+ }190+ if ((await relay.hosts.get(host)) === relay.slug) await relay.hosts.delete(host);191+ save(relay, hosts.filter((x) => x.host !== host));192+ return "";193+}194+195+export function listDomains(relay: Relay): DomainView[] | string {196+ if (!relay.hostnames) return UNSUPPORTED;197+ return stored(relay).map((h) => view(h, relay.cnameTarget, null));198+}199+200+// forgetDomains is teardown's part: best effort, nothing to report to.201+export async function forgetDomains(relay: Relay): Promise<void> {202+ for (const h of stored(relay)) {203+ try {204+ if (relay.hostnames) await relay.hostnames.remove(h.id);205+ } catch (err) {206+ console.log(`custom hostname ${h.host} not removed at Cloudflare: ${err instanceof Error ? err.message : String(err)}`);207+ }208+ try {209+ if ((await relay.hosts.get(h.host)) === relay.slug) await relay.hosts.delete(h.host);210+ } catch (err) {211+ console.log(`custom hostname ${h.host} not removed from KV: ${err instanceof Error ? err.message : String(err)}`);212+ }213+ }214+}@@ -11,6 +11,25 @@ import { now } from "./event.ts";
1111 export { Relay };1212 export { NAME_RE, RESERVED, validName } from "./names.ts";1313 14+// Custom domains map to relay names through KV. Looked up once a minute per15+// hostname per isolate; a miss is cached too, so an unknown host does not16+// cost a read on every request.17+const HOST_TTL_MS = 60_000;18+const hostCache = new Map<string, { name: string | null; at: number }>();19+async function customHost(env: Env, host: string): Promise<string | null> {20+ const hit = hostCache.get(host);21+ if (hit && Date.now() - hit.at < HOST_TTL_MS) return hit.name;22+ let name: string | null = null;23+ try {24+ name = env.HOSTS ? await env.HOSTS.get(host) : null;25+ } catch {26+ name = null;27+ }28+ if (hostCache.size > 10_000) hostCache.clear();29+ hostCache.set(host, { name, at: Date.now() });30+ return name;31+}32+1433 // lease hands out a temporary relay at a memorable name: open to everyone1534 // for a while, wiped after unless claimed. Anyone may ask, no key needed;1635 // a NIP-98 signature reserves the claim for that key. For scripts, agents@@ -62,7 +81,7 @@ export default {
6281 if (host === domain || host === "www." + domain || host === domain + ".localhost") name = null; // apex (<domain>.localhost in wrangler dev)6382 else if (host.endsWith("." + domain)) name = host.slice(0, -(domain.length + 1));6483 else if (host.endsWith(".localhost")) name = host.slice(0, -".localhost".length); // wrangler dev: <name>.localhost65- else name = env.DEV_RELAY; // wrangler dev without a subdomain, previews, custom hostnames84+ else name = (await customHost(env, host)) ?? env.DEV_RELAY; // a custom domain, or wrangler dev without a subdomain6685 6786 if (name === null) {6887 if (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico") return new Response(FAVICON_SVG, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" } });@@ -25,6 +25,7 @@ import { FAVICON_SVG } from "./ui.ts";
2525 import type { PullFilter, PullJob, PullResult } from "./pull.ts";2626 import { leaseDays, leaseNames, validName } from "./names.ts";2727 import { MAX_JOBS, MAX_STANDING, checkJob, finishRun, newJobID, pruneFinished, pullView, runRound, startRun, type Job, type JobSpec } from "./jobs.ts";28+import { Hostnames, forgetDomains } from "./domains.ts";2829 import { groupFacts, handleGroupEvent, isGroupManagement, isGroupState, isNIP43Request } from "./groups.ts";2930 import { KIND_GROUP_MEMBERS } from "./identity.ts";3031 import { SIGNER_JS } from "./gen/signer.ts";@@ -41,6 +42,12 @@ export interface Env {
4142 LEASE_DAYS?: string; // how long a temporary relay lives; 14 by default4243 LEASE_LIMIT_IP: RateLimit; // leases per address per minute4344 LEASE_LIMIT_ALL: RateLimit; // leases per minute, everyone together45+ // Custom domains (see domains.ts): hostname to relay name, and the46+ // Cloudflare for SaaS credentials. Without the token the feature is off.47+ HOSTS: KVNamespace;48+ CF_API_TOKEN?: string;49+ ZONE_ID?: string;50+ CNAME_TARGET?: string; // what owners point their CNAME at; customers.<DOMAIN> if unset4451 // Fuel (see fuel.ts); all optional.4552 LIGHTNING_ADDRESS?: string;4653 SERVICE_PUBKEY?: string;@@ -122,7 +129,7 @@ export class Relay extends DurableObject<Env> {
122129 // When the object last did work, for the active-time meter (ms). Zero after a wake.123130 private lastActive = 0;124131 private lnurl: LnurlParams | null = null;125- // Outbound HTTP to the lightning provider; tests substitute a fake.132+ // Outbound HTTP to the lightning provider and to Cloudflare; tests substitute a fake.126133 fetcher: Fetcher = (u, i) => fetch(u, i);127134 // Succession: a warning month in flight, mirrored from storage so NIP-11128135 // can say so without a read.@@ -130,6 +137,8 @@ export class Relay extends DurableObject<Env> {
130137 private ownerSeenWrite = 0; // ms; the presence write happens at most hourly131138 // A job round in flight, so overlapping alarms do not run two.132139 private working = false;140+ // The custom hostnames client, null when the host has not enabled them.141+ hostnames: Hostnames | null;133142 134143 constructor(ctx: DurableObjectState, env: Env) {135144 super(ctx, env);@@ -137,6 +146,7 @@ export class Relay extends DurableObject<Env> {
137146 this.settings = new Settings(ctx.storage.sql);138147 this.fuel = new Fuel(ctx.storage.sql, fuelConfig(env as unknown as Record<string, unknown>));139148 this.identity = new Identity(ctx.storage);149+ this.hostnames = env.CF_API_TOKEN && env.ZONE_ID ? new Hostnames(env.CF_API_TOKEN, env.ZONE_ID, (u, i) => this.fetcher(u, i)) : null;140150 ctx.blockConcurrencyWhile(async () => {141151 this.store.init();142152 this.settings.load();@@ -160,6 +170,15 @@ export class Relay extends DurableObject<Env> {
160170 return this.env.DOMAIN;161171 }162172 173+ // hosts is the custom domain map the worker routes by.174+ get hosts(): KVNamespace {175+ return this.env.HOSTS;176+ }177+178+ get cnameTarget(): string {179+ return this.env.CNAME_TARGET || "customers." + this.env.DOMAIN;180+ }181+163182 // relays reaches the other objects on this host, for pulls between them.164183 get relays(): DurableObjectNamespace<Relay> {165184 return this.env.RELAY;@@ -332,6 +351,7 @@ export class Relay extends DurableObject<Env> {
332351 // teardown deletes everything this relay holds and returns the name to333352 // unclaimed. Every socket is closed. Nothing is recoverable afterwards.334353 async teardown() {354+ await forgetDomains(this);335355 for (const ws of this.ctx.getWebSockets()) {336356 try {337357 ws.close(4410, "relay deleted");@@ -852,8 +872,10 @@ export class Relay extends DurableObject<Env> {
852872 853873 // relayURL is how this relay is addressed for a given Host header.854874 relayURL(host: string): string {855- const secure = host.endsWith("." + this.env.DOMAIN) || host === this.env.DOMAIN;856- return (secure ? "wss://" : "ws://") + host;875+ // Everything but local development is behind TLS, custom domains included.876+ const h = host.replace(/:\d+$/, "");877+ const local = h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "[::1]";878+ return (local ? "ws://" : "wss://") + host;857879 }858880 859881 // webURL is the same address for a browser.@@ -1,5 +1,6 @@
11 // Per-relay settings and policy, kept in the same SQLite database.22 import { notifySettings, type NotifySettings } from "./notify.ts";3+import type { CustomHost } from "./domains.ts";34 import type { Role } from "./roles.ts";45 56 // A lease makes an unclaimed relay usable for a while: open to everyone,@@ -25,6 +26,7 @@ export interface Policy {
2526 owner: string; // pubkey; "" while unclaimed2627 succession: Succession | null; // off until the owner names an heir2728 lease: Lease | null; // set while the relay is a temporary lease29+ customHosts: CustomHost[]; // this relay's own domains (see domains.ts)2830 name: string;2931 description: string;3032 icon: string;@@ -61,6 +63,7 @@ export interface Policy {
6163 export const DEFAULT_POLICY: Policy = {6264 owner: "",6365 lease: null,66+ customHosts: [],6467 name: "",6568 description: "",6669 icon: "",@@ -422,7 +425,7 @@ export class Settings {
422425 format: "bind.ws/relay-config/1",423426 exported_at: Math.floor(Date.now() / 1000),424427 name,425- policy: { ...this.policy, owner: undefined, lease: undefined, succession: undefined },428+ policy: { ...this.policy, owner: undefined, lease: undefined, succession: undefined, customHosts: undefined },426429 members: this.members().filter((m) => m.role !== "owner").map((m) => ({ pubkey: m.pubkey, name: m.name, note: m.note, ...(m.role === "moderator" ? { role: "moderator" } : {}), ...(m.keep_days ? { keepDays: m.keep_days } : {}), ...(m.max_bytes ? { maxBytes: m.max_bytes } : {}) })),427430 bans: this.listBans(),428431 banned_events: this.listEvents("ban"),@@ -7,15 +7,24 @@
77 "observability": { "enabled": true },88 // Production: the apex as a custom domain, every subdomain routed by the99 // wildcard. The wildcard needs proxied placeholder DNS records for *.bind.ws.10+ // The third route catches custom domains: with Cloudflare for SaaS a11+ // customer's hostname enters this zone, and only a route matching any12+ // host sends it to the Worker (docs/11-hosting-bindws.md, Custom domains).1013 "routes": [1114 { "pattern": "bind.ws", "custom_domain": true },12- { "pattern": "*.bind.ws/*", "zone_id": "491aaf5e1c12976e9828199686a81733" }15+ { "pattern": "*.bind.ws/*", "zone_id": "491aaf5e1c12976e9828199686a81733" },16+ { "pattern": "*/*", "zone_id": "491aaf5e1c12976e9828199686a81733" }1317 ],1418 "vars": {1519 // Relays live at wss://<name>.<DOMAIN>; in `wrangler dev` use <name>.localhost.1620 // Requests for any other host fall back to the relay named DEV_RELAY.1721 "DOMAIN": "bind.ws",1822 "DEV_RELAY": "dev",23+ // Custom domains: the zone custom hostnames are created in, and the name24+ // owners point their CNAME at. CF_API_TOKEN is a secret, set with25+ // `wrangler secret put CF_API_TOKEN`; without it the feature is off.26+ "ZONE_ID": "491aaf5e1c12976e9828199686a81733",27+ "CNAME_TARGET": "customers.bind.ws",1928 // Fuel: free allowances per relay per month, and the sats charged beyond2029 // them, paid by zaps to LIGHTNING_ADDRESS naming SERVICE_PUBKEY. Leave2130 // the address empty to run without top-ups (allowances still apply).@@ -42,6 +51,9 @@
4251 },4352 // Blossom media for every relay, keyed <name>/<sha256> in one bucket.4453 "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "bindws-media" }],54+ // Custom domains: hostname -> relay name, read by the Worker for any55+ // request whose host is outside DOMAIN.56+ "kv_namespaces": [{ "binding": "HOSTS", "id": "77f19f73b8e44ee28085991137feb162" }],4557 // POST /lease creates objects for anyone, so it is capped per address and overall.4658 "ratelimits": [4759 { "name": "LEASE_LIMIT_IP", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } },@@ -57,6 +69,8 @@
5769 "vars": {5870 "DOMAIN": "bind.ws",5971 "DEV_RELAY": "dev",72+ "ZONE_ID": "",73+ "CNAME_TARGET": "customers.bind.ws",6074 "LIGHTNING_ADDRESS": "",6175 "SERVICE_PUBKEY": "",6276 "FREE_EVENTS_MB": "100",@@ -72,6 +86,7 @@
7286 "bindings": [{ "name": "RELAY", "class_name": "Relay" }]7387 },7488 "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "bindws-media" }],89+ "kv_namespaces": [{ "binding": "HOSTS", "id": "77f19f73b8e44ee28085991137feb162" }],7590 "ratelimits": [7691 { "name": "LEASE_LIMIT_IP", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } },7792 { "name": "LEASE_LIMIT_ALL", "namespace_id": "1002", "simple": { "limit": 60, "period": 60 } }