1import * as dns from "@distilled.cloud/cloudflare/dns";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Stream from "effect/Stream";
5import * as FetchHttpClient from "effect/http/FetchHttpClient";
6import type * as HttpClient from "effect/http/HttpClient";
7import { propagationDelay, waitForTxt } from "../../ACME/Dns.ts";
8import {
9 dnsSolverLayer,
10 type DnsChallengeRecord,
11 type DnsSolver,
12 type DnsSolverDescriptor,
13} from "../../ACME/DnsSolver.ts";
14import { DnsSolverError } from "../../ACME/Errors.ts";
15import type { RuntimeContext } from "../../RuntimeContext.ts";
16import type { Credentials } from "../Credentials.ts";
17import type { Input } from "../../Input.ts";
18import type { WriteDnsClient } from "./WriteDns.ts";
19
20/** Solver descriptor: publish `_acme-challenge` TXT records in a Cloudflare zone. */
21export interface CloudflareDnsSolver extends DnsSolverDescriptor {
22 readonly type: "Cloudflare.DNS";
23 readonly zoneId: string;
24}
25
26/**
27 * DNS-01 through a Cloudflare zone's records, for `ACME.Certificate`.
28 * The implementation is registered by `Cloudflare.providers()`.
29 *
30 * **Example:**
31 * ```typescript
32 * const account = yield* LetsEncrypt;
33 * const zone = yield* Zone;
34 * const wildcard = yield* ACME.Certificate("Wildcard", {
35 * account,
36 * identifiers: ["*.example.com"],
37 * solver: Cloudflare.DNS.AcmeSolver(zone),
38 * });
39 * ```
40 */
41export const AcmeSolver = <ZoneId extends Input<string>>(zone: {
42 readonly zoneId: ZoneId;
43}) => ({
44 type: "Cloudflare.DNS" as const,
45 zoneId: zone.zoneId,
46});
47
48const CHALLENGE_TTL = 60;
49/** Post-publish wait for the in-Worker solver. */
50const RUNTIME_PROPAGATION_DELAY = "60 seconds";
51
52const unquote = (content: string | null | undefined): string =>
53 (content ?? "").replace(/^"|"$/g, "");
54
55const solverError = (message: string) => (cause: unknown) =>
56 new DnsSolverError({ message, cause });
57
58/** Deploy-time solver over the distilled DNS SDK (zone id fixed). */
59export const makeCloudflareDnsSolver = (
60 zoneId: string,
61): DnsSolver<Credentials | HttpClient.HttpClient> => {
62 const matching = (record: DnsChallengeRecord) =>
63 dns.listRecords
64 .items({ zoneId, name: { exact: record.fqdn }, type: "TXT" })
65 .pipe(
66 Stream.filter(
67 (r) => r.name === record.fqdn && unquote(r.content) === record.value,
68 ),
69 Stream.runCollect,
70 Effect.map((chunk) => Array.from(chunk)),
71 );
72 return {
73 present: (record) =>
74 Effect.gen(function* () {
75 const existing = yield* matching(record);
76 if (existing.length > 0) return;
77 yield* dns.createRecord({
78 zoneId,
79 type: "TXT",
80 name: record.fqdn,
81 content: record.value,
82 ttl: CHALLENGE_TTL,
83 });
84 }).pipe(
85 Effect.mapError(solverError(`Could not publish TXT ${record.fqdn}`)),
86 ),
87 cleanup: (record) =>
88 Effect.gen(function* () {
89 const existing = yield* matching(record);
90 yield* Effect.forEach(existing, (r) =>
91 dns.deleteRecord({ zoneId, dnsRecordId: r.id }),
92 );
93 }).pipe(
94 Effect.mapError(solverError(`Could not remove TXT ${record.fqdn}`)),
95 ),
96 // Let Cloudflare publish before any intercepted/recursive lookup can cache
97 // a negative answer for the zone's SOA minimum (often 30 minutes).
98 propagated: (record, options) =>
99 waitForTxt(record.fqdn, record.value, {
100 ...options,
101 initialDelay: options.initialDelay ?? "60 seconds",
102 timeout: options.timeout ?? "90 seconds",
103 }),
104 };
105};
106
107/**
108 * Registers the `Cloudflare.DNS` solver type with the ACME provider,
109 * capturing the Cloudflare credentials from the providers layer.
110 */
111export const AcmeDnsSolverLive = dnsSolverLayer(
112 "Cloudflare.DNS",
113 (descriptor) =>
114 typeof descriptor.zoneId === "string" && descriptor.zoneId.length > 0
115 ? Effect.succeed(makeCloudflareDnsSolver(descriptor.zoneId))
116 : Effect.fail(
117 new DnsSolverError({
118 message: "Cloudflare DNS solver requires a zoneId.",
119 }),
120 ),
121).pipe(Layer.provide(FetchHttpClient.layer));
122
123/**
124 * Runtime solver over a bound {@link WriteDnsClient}, for
125 * `ACME.IssueCertificate` inside a Worker or Service.
126 *
127 * **Example:**
128 * ```typescript
129 * const dns = yield* Cloudflare.DNS.WriteDns(Zone);
130 * const issued = yield* acme.issue({
131 * identifiers: ["*.tenant.example.com"],
132 * solver: Cloudflare.DNS.acmeDnsSolver(dns),
133 * });
134 * ```
135 */
136export const acmeDnsSolver = (
137 client: WriteDnsClient,
138): DnsSolver<RuntimeContext> => {
139 const published = new Map<string, string>();
140 const keyOf = (record: DnsChallengeRecord) =>
141 `${record.fqdn}|${record.value}`;
142 return {
143 present: (record) =>
144 client
145 .createDnsRecord({
146 type: "TXT",
147 name: record.fqdn,
148 content: record.value,
149 ttl: CHALLENGE_TTL,
150 })
151 .pipe(
152 Effect.tap((created) =>
153 Effect.sync(() => published.set(keyOf(record), created.id)),
154 ),
155 Effect.asVoid,
156 Effect.mapError(solverError(`Could not publish TXT ${record.fqdn}`)),
157 ),
158 cleanup: (record) =>
159 Effect.suspend(() => {
160 const id = published.get(keyOf(record));
161 if (id === undefined) return Effect.void;
162 return client.deleteDnsRecord(id).pipe(
163 Effect.tap(() => Effect.sync(() => published.delete(keyOf(record)))),
164 Effect.asVoid,
165 Effect.mapError(solverError(`Could not remove TXT ${record.fqdn}`)),
166 );
167 }),
168 // Inside a Worker there is no authoritative lookup and public DoH
169 // resolvers cache stale sets; Cloudflare's edge serves a new record
170 // within seconds, so a fixed wait is the reliable choice here.
171 propagated: (_record, options) =>
172 propagationDelay({ delay: options.delay ?? RUNTIME_PROPAGATION_DELAY }),
173 };
174};