clone

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

dependabot/github_actions/actions-9b61906d8b
bindws / src / domains.ts
domains.ts 12.0K
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.9import type { Fetcher } from "./fuel.ts";10import type { Relay } from "./relay.ts";11import { parseSite } from "./sites.ts";12import { validName } from "./names.ts";1314export interface CustomHost {15 host: string;16 id: string; // Cloudflare's custom hostname id17 // An optional NIP-5A site label served on this hostname. Older entries do18 // not have this field and continue to address the relay itself.19 site?: string;20 at: number; // when last checked21 status: string; // Cloudflare's hostname status, active when it proxies22 sslStatus: string; // certificate status, active when issued23}2425export const MAX_CUSTOM_HOSTS = 3;26export const UNSUPPORTED = "unsupported: custom domains are not enabled on this host";2728const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;29const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;3031// checkHostname normalises and validates a hostname. Returns the hostname32// or a reason: lowercase labels, two or more of them, not the service33// domain or anything under it, not local, not an address.34export function checkHostname(raw: string, domain: string): { host: string } | { error: string } {35 const host = raw.trim().toLowerCase().replace(/\.$/, "");36 const d = domain.toLowerCase();37 if (host.length === 0 || host.length > 253) return { error: "invalid: hostname length" };38 if (host.includes(":") || IPV4.test(host)) return { error: "invalid: an address is not a hostname" };39 const labels = host.split(".");40 if (labels.length < 2 || labels.some((l) => !LABEL.test(l))) return { error: "invalid: not a valid hostname" };41 if (/^\d+$/.test(labels[labels.length - 1])) return { error: "invalid: not a valid hostname" };42 if (host === d || host.endsWith("." + d)) return { error: "invalid: names under " + d + " are relays already" };43 if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return { error: "invalid: that name is local" };44 return { host };45}4647// What Cloudflare tells us about a custom hostname, trimmed to what we show.48export interface HostnameState {49 id: string;50 hostname: string;51 status: string;52 sslStatus: string;53 ownership: { name: string; value: string } | null; // TXT pre-validation record54 dcv: { txtName?: string; txtValue?: string; httpUrl?: string; httpBody?: string }[];55}5657// Hostnames is the Cloudflare API client for the zone's custom hostnames.58// Certificates use HTTP validation, which Cloudflare completes on its own59// once the customer's CNAME resolves to us, so the owner has one record to60// create. The token needs "SSL and Certificates: Edit" on the zone.61export class Hostnames {62 constructor(private token: string, private zone: string, private fetcher: Fetcher) {}6364 private async call(method: string, path: string, body?: unknown): Promise<Record<string, unknown> | null> {65 const resp = await this.fetcher(`https://api.cloudflare.com/client/v4/zones/${this.zone}/custom_hostnames${path}`, {66 method,67 headers: { authorization: "Bearer " + this.token, "content-type": "application/json" },68 body: body === undefined ? undefined : JSON.stringify(body),69 });70 let json: { success?: boolean; result?: Record<string, unknown> | null; errors?: { code?: number; message?: string }[] } = {};71 try {72 json = await resp.json();73 } catch {74 throw new Error(`Cloudflare answered ${resp.status}`);75 }76 if (!json.success) {77 const e = json.errors?.[0];78 if (method === "DELETE" && (resp.status === 404 || e?.code === 1436)) return null;79 throw new Error(e?.message ? `Cloudflare: ${e.message}` : `Cloudflare answered ${resp.status}`);80 }81 return json.result ?? null;82 }8384 async create(hostname: string): Promise<HostnameState> {85 const r = await this.call("POST", "", { hostname, ssl: { method: "http", type: "dv" } });86 if (!r) throw new Error("Cloudflare returned no hostname");87 return parseState(r);88 }8990 async get(id: string): Promise<HostnameState | null> {91 const r = await this.call("GET", "/" + id);92 return r ? parseState(r) : null;93 }9495 async remove(id: string): Promise<void> {96 await this.call("DELETE", "/" + id);97 }98}99100function parseState(r: Record<string, unknown>): HostnameState {101 const ssl = (r.ssl ?? {}) as Record<string, unknown>;102 const ov = (r.ownership_verification ?? null) as { name?: string; value?: string } | null;103 const recs = Array.isArray(ssl.validation_records) ? (ssl.validation_records as Record<string, string>[]) : [];104 return {105 id: String(r.id ?? ""),106 hostname: String(r.hostname ?? ""),107 status: String(r.status ?? "pending"),108 sslStatus: String(ssl.status ?? "pending_validation"),109 ownership: ov && ov.name && ov.value ? { name: ov.name, value: ov.value } : null,110 dcv: recs.map((x) => ({ txtName: x.txt_name, txtValue: x.txt_value, httpUrl: x.http_url, httpBody: x.http_body })),111 };112}113114// DomainView is what the console and API callers get.115export interface DomainView {116 host: string;117 site?: string;118 status: string;119 sslStatus: string;120 ready: boolean;121 checkedAt: number;122 records: { type: string; name: string; value: string; note: string }[];123}124125function view(h: CustomHost, target: string, state: HostnameState | null): DomainView {126 const records: DomainView["records"] = [{ type: "CNAME", name: h.host, value: target, note: "required; the certificate follows once it resolves" }];127 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" });128 return { host: h.host, site: h.site, status: h.status, sslStatus: h.sslStatus, ready: h.status === "active" && h.sslStatus === "active", checkedAt: h.at, records };129}130131function stored(relay: Relay): CustomHost[] {132 return relay.settings.policy.customHosts ?? [];133}134135function save(relay: Relay, hosts: CustomHost[]) {136 relay.settings.update({ customHosts: hosts });137}138139export interface CustomTarget {140 name: string;141 site?: string;142}143144// customTarget reads both the old plain relay name and a mapping with a145// site label, so existing custom domains keep their relay door.146export function customTarget(value: string | null): CustomTarget | null {147 if (!value) return null;148 if (validName(value)) return { name: value };149 try {150 const target = JSON.parse(value) as { name?: unknown; site?: unknown };151 if (typeof target.name !== "string" || !validName(target.name)) return null;152 if (target.site !== undefined && (typeof target.site !== "string" || !parseSite(target.site))) return null;153 return { name: target.name, ...(target.site === undefined ? {} : { site: target.site }) };154 } catch {155 return null;156 }157}158159function targetValue(name: string, site?: string): string {160 return site === undefined ? name : JSON.stringify({ name, site });161}162163// addDomain registers a hostname with Cloudflare, then maps it in KV and164// remembers it. KV is written only after Cloudflare accepted the hostname.165export async function addDomain(relay: Relay, raw: string, site?: string): Promise<DomainView | string> {166 const api = relay.hostnames;167 const kv = relay.hosts;168 if (!api || !kv) return UNSUPPORTED;169 const c = checkHostname(raw, relay.domain);170 if ("error" in c) return c.error;171 if (site !== undefined && !parseSite(site)) return "invalid: not a valid site hostname";172 const hosts = stored(relay);173 if (hosts.some((h) => h.host === c.host)) return "duplicate: that hostname is already on this relay";174 if (hosts.length >= MAX_CUSTOM_HOSTS) return `restricted: at most ${MAX_CUSTOM_HOSTS} custom domains per relay`;175 const taken = customTarget(await kv.get(c.host));176 if (taken && taken.name !== relay.slug) return "restricted: that hostname points at another relay";177 let state: HostnameState;178 try {179 state = await api.create(c.host);180 } catch (err) {181 return "error: " + (err instanceof Error ? err.message : String(err));182 }183 await kv.put(c.host, targetValue(relay.slug, site));184 const h: CustomHost = { host: c.host, ...(site === undefined ? {} : { site }), id: state.id, at: Math.floor(Date.now() / 1000), status: state.status, sslStatus: state.sslStatus };185 save(relay, [...hosts, h]);186 return view(h, relay.cnameTarget, state);187}188189// setDomainSite changes a registered hostname's destination. Its existing190// certificate and DNS records stay valid because only the routing changes.191export async function setDomainSite(relay: Relay, raw: string, site?: string): Promise<DomainView | string> {192 if (!relay.hostnames || !relay.hosts) return UNSUPPORTED;193 const host = raw.trim().toLowerCase();194 const hosts = stored(relay);195 const h = hosts.find((x) => x.host === host);196 if (!h) return "invalid: no such custom domain on this relay";197 if (site !== undefined && !parseSite(site)) return "invalid: not a valid site hostname";198 const taken = customTarget(await relay.hosts.get(host));199 if (taken && taken.name !== relay.slug) return "restricted: that hostname points at another relay";200 await relay.hosts.put(host, targetValue(relay.slug, site));201 const fresh = { ...h, site };202 save(relay, hosts.map((x) => x.host === host ? fresh : x));203 return view(fresh, relay.cnameTarget, null);204}205206// checkDomain asks Cloudflare where the hostname stands and remembers it.207export async function checkDomain(relay: Relay, raw: string): Promise<DomainView | string> {208 const api = relay.hostnames;209 if (!api) return UNSUPPORTED;210 const host = raw.trim().toLowerCase();211 const hosts = stored(relay);212 const h = hosts.find((x) => x.host === host);213 if (!h) return "invalid: no such custom domain on this relay";214 let state: HostnameState | null;215 try {216 state = await api.get(h.id);217 } catch (err) {218 return "error: " + (err instanceof Error ? err.message : String(err));219 }220 if (!state) return "error: Cloudflare no longer knows this hostname; remove it and add it again";221 const fresh: CustomHost = { ...h, at: Math.floor(Date.now() / 1000), status: state.status, sslStatus: state.sslStatus };222 save(relay, hosts.map((x) => (x.host === host ? fresh : x)));223 return view(fresh, relay.cnameTarget, state);224}225226// removeDomain deletes the hostname at Cloudflare, then the mapping.227export async function removeDomain(relay: Relay, raw: string): Promise<string> {228 const api = relay.hostnames;229 if (!api) return UNSUPPORTED;230 const host = raw.trim().toLowerCase();231 const hosts = stored(relay);232 const h = hosts.find((x) => x.host === host);233 if (!h) return "invalid: no such custom domain on this relay";234 try {235 await api.remove(h.id);236 } catch (err) {237 return "error: " + (err instanceof Error ? err.message : String(err));238 }239 const kv = relay.hosts;240 if (kv && customTarget(await kv.get(host))?.name === relay.slug) await kv.delete(host);241 save(relay, hosts.filter((x) => x.host !== host));242 return "";243}244245export function listDomains(relay: Relay): DomainView[] | string {246 if (!relay.hostnames) return UNSUPPORTED;247 return stored(relay).map((h) => view(h, relay.cnameTarget, null));248}249250// forgetDomains is teardown's part: best effort, nothing to report to.251export async function forgetDomains(relay: Relay): Promise<void> {252 for (const h of stored(relay)) {253 try {254 if (relay.hostnames) await relay.hostnames.remove(h.id);255 } catch (err) {256 console.log(`custom hostname ${h.host} not removed at Cloudflare: ${err instanceof Error ? err.message : String(err)}`);257 }258 try {259 const kv = relay.hosts;260 if (kv && customTarget(await kv.get(h.host))?.name === relay.slug) await kv.delete(h.host);261 } catch (err) {262 console.log(`custom hostname ${h.host} not removed from KV: ${err instanceof Error ? err.message : String(err)}`);263 }264 }265}