Skip to content
169 linesCodeBlameRaw

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.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1/**
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
15const API = "https://api.cloudflare.com/client/v4";
16
17/** Cloudflare's codes for a zone without Cloudflare for SaaS. */
18const NOT_ENABLED_CODES = new Set([1404, 1456]);
19
20/** Thrown while Cloudflare for SaaS is not turned on for the zone. */
21export class NotEnabled extends Error {
22 constructor(message: string) {
23 super(message);
24 this.name = "NotEnabled";
25 }
26}
27
28/** Cloudflare refused, with its codes. */
29export 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. */
41export 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. */
51export 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
65export type DomainStatus = "pending" | "verifying" | "active" | "failed";
66
67/** Cloudflare's view of a hostname, as a domain's status and what to show. */
68export 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 */
87export 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
104export 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}

This file's history is long; its oldest lines are credited to the oldest commit read.