/** * DNS providers a site owner can hand us a key for, so the custom-domain * records get created for them. Credentials arrive in one request, are used * for that request, and are never stored or logged. */ export interface DnsRecordSpec { type: string; name: string; value: string; } export interface DnsApplyResult { provider: string; zone: string; created: string[]; updated: string[]; unchanged: string[]; } export type DnsCredentials = Record; interface DnsProvider { id: string; label: string; /** Credential fields the admin form asks for, in order. */ fields: Array<{ key: string; label: string; secret: boolean }>; /** Nameserver suffixes that identify a zone hosted here (lower-case). */ nameservers: string[]; apply(creds: DnsCredentials, records: DnsRecordSpec[]): Promise; } class DnsProviderError extends Error {} const UA = "premium-cms/1.0 (custom-domain dns setup)"; const TRAILING_DOT = /\.$/; const TXT_QUOTES = /^"|"$/g; function normalizeName(name: string): string { return name.trim().replace(TRAILING_DOT, "").toLowerCase(); } /** Candidate zones for a hostname, longest first: a.b.example.com → [a.b.example.com, b.example.com, example.com]. */ function zoneCandidates(hostname: string): string[] { const labels = normalizeName(hostname).split("."); const out: string[] = []; for (let i = 0; i < labels.length - 1; i++) out.push(labels.slice(i).join(".")); return out; } function sameValue(type: string, a: string, b: string): boolean { const na = a.trim().replace(TRAILING_DOT, ""); const nb = b.trim().replace(TRAILING_DOT, ""); return type === "TXT" ? na.replace(TXT_QUOTES, "") === nb.replace(TXT_QUOTES, "") : na.toLowerCase() === nb.toLowerCase(); } /* ── Cloudflare ────────────────────────────────────────────────────── */ async function cf(token: string, method: string, path: string, body?: unknown): Promise { const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": UA, }, body: body === undefined ? undefined : JSON.stringify(body), }); const data = (await res.json().catch(() => null)) as { success?: boolean; result?: T; errors?: Array<{ code: number; message: string }>; } | null; if (!res.ok || !data?.success) { const msg = data?.errors?.map((e) => e.message).join("; ") || `HTTP ${res.status}`; if (data?.errors?.some((e) => e.code === 6003 || e.code === 6111)) { throw new DnsProviderError( "Cloudflare didn't accept that as an API token. Use an API Token (My Profile → API Tokens → Create, with Zone → DNS → Edit), not the Global API Key.", ); } if (res.status === 401 || res.status === 403) throw new DnsProviderError(`Cloudflare rejected the token: ${msg}`); throw new DnsProviderError(`Cloudflare: ${msg}`); } return data.result as T; } const cloudflare: DnsProvider = { id: "cloudflare", label: "Cloudflare", fields: [{ key: "token", label: "API token (Zone → DNS → Edit)", secret: true }], nameservers: [".ns.cloudflare.com"], async apply(creds, records) { const token = (creds.token ?? "").trim(); if (!token) throw new DnsProviderError("An API token is required."); const host = records[0]?.name ?? ""; let zone: { id: string; name: string } | undefined; for (const cand of zoneCandidates(host)) { const zones = await cf>( token, "GET", `/zones?name=${encodeURIComponent(cand)}`, ); if (zones[0]) { zone = zones[0]; break; } } if (!zone) throw new DnsProviderError(`No Cloudflare zone for ${host} is reachable with this token.`); const out: DnsApplyResult = { provider: "cloudflare", zone: zone.name, created: [], updated: [], unchanged: [], }; for (const r of records) { const name = normalizeName(r.name); const existing = await cf>( token, "GET", `/zones/${zone.id}/dns_records?type=${r.type}&name=${encodeURIComponent(name)}`, ); const label = `${r.type} ${name}`; // Custom hostnames must stay DNS-only at the customer's zone: a // proxied CNAME would terminate at their Cloudflare, not ours. const body = { type: r.type, name, content: r.value, ttl: 300, proxied: false }; const match = existing.find((e) => sameValue(r.type, e.content, r.value)); if (existing.length === 0) { await cf(token, "POST", `/zones/${zone.id}/dns_records`, body); out.created.push(label); } else if (match && !match.proxied) { out.unchanged.push(label); } else if (match) { // Right target, but proxied: the customer's Cloudflare would terminate // the request itself and never reach our fallback origin (a 522). await cf(token, "PATCH", `/zones/${zone.id}/dns_records/${match.id}`, { proxied: false }); out.updated.push(`${label} (proxy turned off)`); } else { await cf(token, "PUT", `/zones/${zone.id}/dns_records/${existing[0]!.id}`, body); out.updated.push(label); } } return out; }, }; /* ── Simply.com ────────────────────────────────────────────────────── */ async function simply(auth: string, method: string, path: string, body?: unknown): Promise { const res = await fetch(`https://api.simply.com/2${path}`, { method, headers: { Authorization: auth, "Content-Type": "application/json", "User-Agent": UA }, body: body === undefined ? undefined : JSON.stringify(body), }); const data = (await res.json().catch(() => null)) as (T & { message?: string }) | null; if (!res.ok) { if (res.status === 401 || res.status === 403) throw new DnsProviderError("Simply.com rejected the account/API key."); if (res.status === 404) throw new DnsProviderError("Simply.com: domain not found on this account."); throw new DnsProviderError(`Simply.com: ${data?.message || `HTTP ${res.status}`}`); } return data as T; } const simplyCom: DnsProvider = { id: "simply", label: "Simply.com", fields: [ { key: "account", label: "Account name (S123456)", secret: false }, { key: "apiKey", label: "API key", secret: true }, ], nameservers: [".simply.com", ".unoeuro.com"], async apply(creds, records) { const account = (creds.account ?? "").trim(); const apiKey = (creds.apiKey ?? "").trim(); if (!account || !apiKey) throw new DnsProviderError("Account name and API key are required."); const auth = `Basic ${btoa(`${account}:${apiKey}`)}`; const host = records[0]?.name ?? ""; let zone: string | undefined; let existing: Array<{ record_id: number; name: string; type: string; data: string }> = []; for (const cand of zoneCandidates(host)) { try { const r = await simply<{ records?: typeof existing }>( auth, "GET", `/my/products/${encodeURIComponent(cand)}/dns/records/`, ); zone = cand; existing = r.records ?? []; break; } catch (e) { if (!(e instanceof DnsProviderError) || !e.message.includes("not found")) throw e; } } if (!zone) throw new DnsProviderError(`No Simply.com DNS zone for ${host} on this account.`); const out: DnsApplyResult = { provider: "simply", zone, created: [], updated: [], unchanged: [], }; for (const r of records) { const name = normalizeName(r.name); const label = `${r.type} ${name}`; const match = existing.filter((e) => e.type === r.type && normalizeName(e.name) === name); const body = { type: r.type, name, data: r.value, ttl: 300 }; if (match.length === 0) { await simply(auth, "POST", `/my/products/${encodeURIComponent(zone)}/dns/records/`, body); out.created.push(label); } else if (match.some((e) => sameValue(r.type, e.data, r.value))) { out.unchanged.push(label); } else { await simply( auth, "PUT", `/my/products/${encodeURIComponent(zone)}/dns/records/${match[0]!.record_id}/`, body, ); out.updated.push(label); } } return out; }, }; export const DNS_PROVIDERS: Record = { [cloudflare.id]: cloudflare, [simplyCom.id]: simplyCom, }; /** Provider catalogue for the admin form (no secrets). */ export function listDnsProviders(): Array<{ id: string; label: string; fields: DnsProvider["fields"]; }> { return Object.values(DNS_PROVIDERS).map(({ id, label, fields }) => ({ id, label, fields })); } /** * Guess the DNS provider of a hostname from the nameservers of the closest * zone that has any (DNS-over-HTTPS, so it works from a Worker). Returns the * provider id, or null when the nameservers belong to none we support. */ export async function detectDnsProvider( hostname: string, ): Promise<{ provider: string | null; nameservers: string[] }> { for (const zone of zoneCandidates(hostname)) { let answers: Array<{ type: number; data: string }> = []; try { const res = await fetch( `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(zone)}&type=NS`, { headers: { accept: "application/dns-json", "User-Agent": UA } }, ); answers = ((await res.json()) as { Answer?: typeof answers }).Answer ?? []; } catch { continue; } const ns = answers .filter((a) => a.type === 2) .map((a) => a.data.toLowerCase().replace(TRAILING_DOT, "")); if (ns.length === 0) continue; const hit = Object.values(DNS_PROVIDERS).find((p) => ns.some((n) => p.nameservers.some((suffix) => n.endsWith(suffix))), ); return { provider: hit?.id ?? null, nameservers: ns }; } return { provider: null, nameservers: [] }; } export async function applyDnsRecords( providerId: string, creds: DnsCredentials, records: DnsRecordSpec[], ): Promise { const provider = DNS_PROVIDERS[providerId]; if (!provider) throw new DnsProviderError(`Unknown DNS provider "${providerId}".`); if (records.length === 0) throw new DnsProviderError("There are no records to add."); return provider.apply(creds, records); } export { DnsProviderError };