g1t/services/deployments/src/custom-hostnames.ts
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 1 | /** |
| 2 | * Cloudflare for SaaS: custom hostnames on the g1t.page zone. A hostname | |
| 3 | * someone points at `domains.g1t.page` is added here; Cloudflare checks | |
| 4 | * they own it, issues its certificate, and sends its traffic through the | |
| 5 | * zone, where the dispatcher's `*\/*` route serves it. | |
| 6 | * | |
| 7 | * The token needs SSL and Certificates: Edit on the zone, beside what the | |
| 8 | * service's token already holds. Until Cloudflare for SaaS is turned on for | |
| 9 | * the zone, Cloudflare answers with codes 1404 or 1456; that is reported as | |
| 10 | * `NotEnabled`, so callers can say so instead of failing. | |
| 11 | * | |
| 12 | * Kept free of imports so its tests run on Node as they are. | |
| 13 | */ | |
| 14 | ||
| 15 | const API = "https://api.cloudflare.com/client/v4"; | |
| 16 | ||
| 17 | /** Cloudflare's codes for a zone without Cloudflare for SaaS. */ | |
| 18 | const NOT_ENABLED_CODES = new Set([1404, 1456]); | |
| 19 | ||
| 20 | /** Thrown while Cloudflare for SaaS is not turned on for the zone. */ | |
| 21 | export class NotEnabled extends Error { | |
| 22 | constructor(message: string) { | |
| 23 | super(message); | |
| 24 | this.name = "NotEnabled"; | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | /** Cloudflare refused, with its codes. */ | |
| 29 | export class Refused extends Error { | |
| 30 | readonly status: number; | |
| 31 | readonly codes: number[]; | |
| 32 | constructor(message: string, status: number, codes: number[]) { | |
| 33 | super(message); | |
| 34 | this.name = "Refused"; | |
| 35 | this.status = status; | |
| 36 | this.codes = codes; | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | /** A record the domain's owner adds at their DNS provider. */ | |
| 41 | export type DnsRecord = { | |
| 42 | type: "CNAME" | "TXT" | "ALIAS"; | |
| 43 | /** The record's full name. */ | |
| 44 | name: string; | |
| 45 | value: string; | |
| 46 | /** What it is for, in a few words. */ | |
| 47 | purpose: string; | |
| 48 | }; | |
| 49 | ||
| 50 | /** A custom hostname as Cloudflare answers with it, the fields used. */ | |
| 51 | export type CustomHostname = { | |
| 52 | id: string; | |
| 53 | hostname: string; | |
| 54 | status: string; | |
| 55 | ssl?: { | |
| 56 | status?: string; | |
| 57 | method?: string; | |
| 58 | validation_records?: { txt_name?: string; txt_value?: string; cname?: string; cname_target?: string; http_url?: string; http_body?: string }[]; | |
| 59 | validation_errors?: { message: string }[]; | |
| 60 | }; | |
| 61 | ownership_verification?: { type?: string; name?: string; value?: string }; | |
| 62 | verification_errors?: string[]; | |
| 63 | }; | |
| 64 | ||
| 65 | export type DomainStatus = "pending" | "verifying" | "active" | "failed"; | |
| 66 | ||
| 67 | /** Cloudflare's view of a hostname, as a domain's status and what to show. */ | |
| 68 | export function statusOf(found: CustomHostname): { status: DomainStatus; ssl: string | null; error: string | null } { | |
| 69 | const ssl = found.ssl?.status ?? null; | |
| 70 | const errors = [...(found.verification_errors ?? []), ...(found.ssl?.validation_errors ?? []).map((e) => e.message)]; | |
| 71 | const error = errors.length ? errors.join(" ") : null; | |
| 72 | if (["blocked", "moved", "deleted"].includes(found.status)) return { status: "failed", ssl, error: error ?? `Cloudflare marked it ${found.status}.` }; | |
| 73 | if (ssl && ["validation_timed_out", "issuance_timed_out", "expired", "deleted"].includes(ssl)) { | |
| 74 | return { status: "failed", ssl, error: error ?? "The certificate could not be issued in time. Check the records, then check again." }; | |
| 75 | } | |
| 76 | if (found.status === "active" && ssl === "active") return { status: "active", ssl, error: null }; | |
| 77 | if (found.status === "active") return { status: "verifying", ssl, error }; | |
| 78 | return { status: "pending", ssl, error }; | |
| 79 | } | |
| 80 | ||
| 81 | /** | |
| 82 | * The records Cloudflare asks for: the TXT that proves ownership before | |
| 83 | * traffic moves, and any TXT or CNAME the certificate's validation needs. | |
| 84 | * HTTP validation needs nothing in DNS: Cloudflare answers it once the | |
| 85 | * hostname points here. | |
| 86 | */ | |
| 87 | export function recordsOf(found: CustomHostname): DnsRecord[] { | |
| 88 | const records: DnsRecord[] = []; | |
| 89 | const own = found.ownership_verification; | |
| 90 | if (own?.name && own.value) { | |
| 91 | records.push({ type: "TXT", name: own.name, value: own.value, purpose: "Proves you own the domain" }); | |
| 92 | } | |
| 93 | for (const record of found.ssl?.validation_records ?? []) { | |
| 94 | if (record.txt_name && record.txt_value) { | |
| 95 | records.push({ type: "TXT", name: record.txt_name, value: record.txt_value, purpose: "Validates the certificate" }); | |
| 96 | } | |
| 97 | if (record.cname && record.cname_target) { | |
| 98 | records.push({ type: "CNAME", name: record.cname, value: record.cname_target, purpose: "Validates the certificate" }); | |
| 99 | } | |
| 100 | } | |
| 101 | return records; | |
| 102 | } | |
| 103 | ||
| 104 | export class CustomHostnames { | |
| 105 | private readonly token: string; | |
| 106 | private readonly zone: string; | |
| 107 | constructor(token: string, zone: string) { | |
| 108 | this.token = token; | |
| 109 | this.zone = zone; | |
| 110 | } | |
| 111 | ||
| 112 | private async call<T>(method: string, path: string, body?: unknown): Promise<T> { | |
| 113 | const response = await fetch(`${API}/zones/${this.zone}/custom_hostnames${path}`, { | |
| 114 | method, | |
| 115 | headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" }, | |
| 116 | body: body === undefined ? undefined : JSON.stringify(body), | |
| 117 | }); | |
| 118 | const answer = (await response.json().catch(() => null)) as { | |
| 119 | success?: boolean; | |
| 120 | result?: T; | |
| 121 | errors?: { code: number; message: string }[]; | |
| 122 | } | null; | |
| 123 | if (!response.ok || !answer?.success) { | |
| 124 | const errors = answer?.errors ?? []; | |
| 125 | const why = errors.map((error) => `${error.message} (${error.code})`).join("; ") || "no reason given"; | |
| 126 | if (errors.some((error) => NOT_ENABLED_CODES.has(error.code)) || /ssl for saas|not.*(entitled|provisioned)/i.test(why)) { | |
| 127 | throw new NotEnabled(why); | |
| 128 | } | |
| 129 | throw new Refused(`Cloudflare answered ${response.status}: ${why}`, response.status, errors.map((e) => e.code)); | |
| 130 | } | |
| 131 | return answer.result as T; | |
| 132 | } | |
| 133 | ||
| 134 | /** Adds `hostname`, with a certificate validated over HTTP once it points here. */ | |
| 135 | async create(hostname: string, metadata: Record<string, string>): Promise<CustomHostname> { | |
| 136 | return this.call<CustomHostname>("POST", "", { | |
| 137 | hostname, | |
| 138 | ssl: { method: "http", type: "dv", settings: { min_tls_version: "1.2" } }, | |
| 139 | custom_metadata: metadata, | |
| 140 | }); | |
| 141 | } | |
| 142 | ||
| 143 | async get(id: string): Promise<CustomHostname> { | |
| 144 | return this.call<CustomHostname>("GET", `/${encodeURIComponent(id)}`); | |
| 145 | } | |
| 146 | ||
| 147 | /** The hostname, if it is on the zone already, as when an add half-finished. */ | |
| 148 | async find(hostname: string): Promise<CustomHostname | null> { | |
| 149 | const found = await this.call<CustomHostname[]>("GET", `?hostname=${encodeURIComponent(hostname)}`); | |
| 150 | return found.find((h) => h.hostname === hostname) ?? null; | |
| 151 | } | |
| 152 | ||
| 153 | /** Asks Cloudflare to check the hostname again now: a PATCH with its SSL settings. */ | |
| 154 | async recheck(id: string): Promise<CustomHostname> { | |
| 155 | return this.call<CustomHostname>("PATCH", `/${encodeURIComponent(id)}`, { | |
| 156 | ssl: { method: "http", type: "dv", settings: { min_tls_version: "1.2" } }, | |
| 157 | }); | |
| 158 | } | |
| 159 | ||
| 160 | /** Removes it. Already gone is fine. */ | |
| 161 | async delete(id: string): Promise<void> { | |
| 162 | try { | |
| 163 | await this.call("DELETE", `/${encodeURIComponent(id)}`); | |
| 164 | } catch (error) { | |
| 165 | if (error instanceof Refused && error.status === 404) return; | |
| 166 | throw error; | |
| 167 | } | |
| 168 | } | |
| 169 | } |