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 / edge.ts
edge.ts 4.7K
1// What the edge in front of the Worker provides, and what stands in when it2// is not Cloudflare's: the client's address, the lease door's rate limit,3// the custom hostname map, and the answer to a proxy asking whether a4// hostname belongs here. Hosting without Cloudflare is5// docs/16-hosting-without-cloudflare.md.6import { customTarget } from "./domains.ts";7import type { Env } from "./relay.ts";8import { Bucket } from "./ratelimit.ts";9import { parseSite, siteKey } from "./sites.ts";10import { RESERVED, validName } from "./names.ts";1112// Custom domains map to relay names through KV. Looked up once a minute per13// hostname per isolate; a miss is cached too, so an unknown host does not14// cost a read on every request.15const HOST_TTL_MS = 60_000;16const hostCache = new Map<string, { name: string | null; at: number }>();17export async function customHost(env: Pick<Env, "HOSTS">, host: string): Promise<string | null> {18 const hit = hostCache.get(host);19 if (hit && Date.now() - hit.at < HOST_TTL_MS) return hit.name;20 let name: string | null = null;21 try {22 name = env.HOSTS ? await env.HOSTS.get(host) : null;23 } catch {24 name = null;25 }26 if (hostCache.size > 10_000) hostCache.clear();27 hostCache.set(host, { name, at: Date.now() });28 return name;29}3031// Site routes are read through KV without caching misses: publishers often32// open the URL immediately after the relay acknowledges their event.33export async function siteHost(env: Pick<Env, "HOSTS">, label: string): Promise<string | null> {34 if (!parseSite(label) || !env.HOSTS) return null;35 try {36 const value = await env.HOSTS.get<{ name: string }>(siteKey(label), "json");37 return value && validName(value.name) ? value.name : null;38 } catch { return null; }39}4041// clientIP is the address the edge saw. On Cloudflare that is42// cf-connecting-ip, which the edge sets and nothing a client sends can43// forge. Elsewhere it is the header CLIENT_IP_HEADER names, written by the44// operator's own proxy; the last address in a comma list is the one that45// proxy appended. An empty CLIENT_IP_HEADER means no address is known, and46// everything keyed by address stands down (relay.ts, ipLimit).47export function clientIP(req: Request, env: Pick<Env, "CLIENT_IP_HEADER">): string {48 const header = env.CLIENT_IP_HEADER ?? "cf-connecting-ip";49 if (header === "") return "unknown";50 const last = (req.headers.get(header) ?? "").split(",").pop()?.trim() ?? "";51 return last || "unknown";52}5354// The lease door's width when there is no rate limit binding: the same55// figures as `ratelimits` in wrangler.jsonc, kept per isolate, which on56// one celld node is the whole relay.57export const LEASES_PER_IP_MINUTE = 5;58export const LEASES_PER_MINUTE = 60;59const leaseBuckets = new Map<string, Bucket>();60let leasesAll: Bucket | null = null;6162// leaseAllowed spends one lease from the address's allowance and from63// everyone's: Cloudflare's rate limit bindings where they exist, token64// buckets in memory where they do not.65export async function leaseAllowed(env: Pick<Env, "LEASE_LIMIT_IP" | "LEASE_LIMIT_ALL">, ip: string): Promise<boolean> {66 if (env.LEASE_LIMIT_IP && env.LEASE_LIMIT_ALL) {67 const [perIP, all] = await Promise.all([env.LEASE_LIMIT_IP.limit({ key: ip }), env.LEASE_LIMIT_ALL.limit({ key: "all" })]);68 return perIP.success && all.success;69 }70 if (leaseBuckets.size > 10_000) leaseBuckets.clear();71 let b = leaseBuckets.get(ip);72 if (!b) {73 b = new Bucket(LEASES_PER_IP_MINUTE);74 leaseBuckets.set(ip, b);75 }76 leasesAll ??= new Bucket(LEASES_PER_MINUTE);77 const mine = b.take() === "";78 const everyone = leasesAll.take() === "";79 return mine && everyone;80}8182// hostnameKnown says whether a hostname is one of ours: the apex, a valid83// or reserved name under it (a reserved one redirects to the apex, which84// needs a certificate too), or a custom hostname in the map. A proxy that85// issues certificates on demand asks this before it asks a certificate86// authority, so a stranger cannot make it request certificates for junk.87export async function hostnameKnown(env: Pick<Env, "DOMAIN" | "HOSTS">, raw: string): Promise<boolean> {88 const host = raw.toLowerCase().replace(/\.$/, "");89 const domain = env.DOMAIN.toLowerCase();90 if (host === domain || host === "www." + domain) return true;91 if (host.endsWith("." + domain)) {92 const name = host.slice(0, -(domain.length + 1));93 return validName(name) || RESERVED.has(name) || (await siteHost(env, name)) !== null;94 }95 return host !== "" && customTarget(await customHost(env, host)) !== null;96}9798// hostOf lower-cases the hostname of a URL, ignoring scheme, port and path.99export function hostOf(s: string): string {100 try {101 return new URL(s.trim()).hostname.toLowerCase();102 } catch {103 return "";104 }105}