1import * as dns from "@distilled.cloud/cloudflare/dns";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11import { listAllZones } from "../Zone/lookup.ts";
12
13const TypeId = "Cloudflare.DNS.Dnssec" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Live DNSSEC status as Cloudflare reports it. `pending` /
18 * `pending-disabled` are the eventually-consistent transitions toward
19 * `active` / `disabled`; reaching `active` additionally requires the DS
20 * record to be submitted at the registrar.
21 */
22export type DnssecStatus =
23 | "active"
24 | "pending"
25 | "disabled"
26 | "pending-disabled"
27 | "error"
28 | (string & {});
29
30/**
31 * The user-desired DNSSEC state — Cloudflare's PATCH endpoint only
32 * accepts these two values.
33 */
34export type DnssecDesiredStatus = "active" | "disabled";
35
36export interface DnssecProps {
37 /**
38 * Zone whose DNSSEC configuration is managed. Stable — DNSSEC is a
39 * per-zone singleton, so changing the zone triggers a replacement
40 * (the old zone's DNSSEC is restored to its pre-management state).
41 */
42 zoneId: string;
43 /**
44 * Desired DNSSEC state. `"active"` signs the zone; `"disabled"` turns
45 * signing off. Activation is eventually consistent — Cloudflare
46 * reports `pending` until the registrar-side DS record is in place.
47 *
48 * Mutable — patched in place.
49 *
50 * @default "active"
51 */
52 status?: DnssecDesiredStatus;
53 /**
54 * Enable multi-signer DNSSEC, allowing multiple providers to serve a
55 * DNSSEC-signed zone at the same time (required for user-managed
56 * DNSKEY records).
57 *
58 * Mutable — patched in place.
59 *
60 * @default false
61 */
62 dnssecMultiSigner?: boolean;
63 /**
64 * Allow Cloudflare to transfer in a pre-signed zone (signatures
65 * included) from an external provider without signing on the fly.
66 *
67 * Mutable — patched in place.
68 *
69 * @default false
70 */
71 dnssecPresigned?: boolean;
72 /**
73 * Use NSEC3 together with DNSSEC. Combined with `dnssecPresigned`,
74 * enables NSEC3 records when transferring in from an external
75 * provider (live signing with NSEC3 requires Foundation DNS).
76 *
77 * Mutable — patched in place.
78 *
79 * @default false
80 */
81 dnssecUseNsec3?: boolean;
82}
83
84export interface DnssecAttributes {
85 /** Zone whose DNSSEC configuration is managed. */
86 zoneId: string;
87 /**
88 * Live DNSSEC status. Stays `pending` until the DS record is
89 * submitted at the registrar — that final hop is outside Cloudflare's
90 * (and Alchemy's) control.
91 */
92 status: DnssecStatus;
93 /** Whether multi-signer DNSSEC is enabled. */
94 dnssecMultiSigner: boolean | undefined;
95 /** Whether pre-signed zone transfers are enabled. */
96 dnssecPresigned: boolean | undefined;
97 /** Whether NSEC3 is enabled. */
98 dnssecUseNsec3: boolean | undefined;
99 /** DNSSEC algorithm key code (e.g. `"13"`). */
100 algorithm: string | undefined;
101 /** Digest hash for the DS record. */
102 digest: string | undefined;
103 /** Digest algorithm name (e.g. `"SHA256"`). */
104 digestAlgorithm: string | undefined;
105 /** Coded digest algorithm type (e.g. `"2"`). */
106 digestType: string | undefined;
107 /**
108 * Full DS record string — this is what users paste at their registrar
109 * to complete DNSSEC activation.
110 */
111 ds: string | undefined;
112 /** DNSKEY flags field (e.g. `257` for KSK). */
113 flags: number | undefined;
114 /** DS key tag. */
115 keyTag: number | undefined;
116 /** Algorithm key type (e.g. `"ECDSAP256SHA256"`). */
117 keyType: string | undefined;
118 /** Public key for the DS record. */
119 publicKey: string | undefined;
120 /** When DNSSEC was last modified. */
121 modifiedOn: string | undefined;
122 /**
123 * The desired-state family DNSSEC was in before Alchemy first managed
124 * it (`pending` normalises to `active`, `pending-disabled` to
125 * `disabled`). Restored on destroy.
126 */
127 initialStatus: DnssecDesiredStatus;
128 /** `dnssecMultiSigner` before Alchemy first managed the zone. */
129 initialMultiSigner: boolean | undefined;
130 /** `dnssecPresigned` before Alchemy first managed the zone. */
131 initialPresigned: boolean | undefined;
132 /** `dnssecUseNsec3` before Alchemy first managed the zone. */
133 initialUseNsec3: boolean | undefined;
134}
135
136export type Dnssec = Resource<
137 TypeId,
138 DnssecProps,
139 DnssecAttributes,
140 never,
141 Providers
142>;
143
144/**
145 * DNSSEC configuration for a Cloudflare zone
146 * (`/zones/{zone_id}/dnssec`).
147 *
148 * DNSSEC is a per-zone singleton — it always exists in either an
149 * enabled or disabled state, so this resource never creates or deletes
150 * anything physical. Reconcile patches the configuration toward the
151 * desired state; destroy restores the state the zone had before
152 * Alchemy first managed it (enabled stays enabled, previously-disabled
153 * zones are deactivated again).
154 *
155 * Activation is eventually consistent: after enabling, Cloudflare
156 * reports `pending` until the `ds` attribute (the DS record) is
157 * submitted at the domain's registrar. The reconciler polls with
158 * bounded retries for the zone to leave the `disabled` state but does
159 * not wait for full `active` — that depends on the registrar.
160 *
161 * Safety: when there is no prior state and DNSSEC is already enabled
162 * on the zone, `read` reports it as `Unowned` and the engine refuses
163 * to take it over unless `--adopt` (or `adopt(true)`) is set.
164 * ### Enabling DNSSEC
165 * **Example:** Sign the zone
166 * ```typescript
167 * const dnssec = yield* Cloudflare.DNS.Dnssec("ZoneDnssec", {
168 * zoneId: zone.zoneId,
169 * });
170 * // Paste `dnssec.ds` at your registrar to complete activation.
171 * ```
172 *
173 * **Example:** Multi-signer DNSSEC
174 * ```typescript
175 * yield* Cloudflare.DNS.Dnssec("ZoneDnssec", {
176 * zoneId: zone.zoneId,
177 * dnssecMultiSigner: true,
178 * });
179 * ```
180 *
181 * ### Disabling DNSSEC
182 * **Example:** Keep DNSSEC explicitly off
183 * ```typescript
184 * yield* Cloudflare.DNS.Dnssec("ZoneDnssec", {
185 * zoneId: zone.zoneId,
186 * status: "disabled",
187 * });
188 * ```
189 *
190 * @resource
191 * @product DNS
192 * @category Domains & DNS
193 */
194export const Dnssec = Resource<Dnssec>(TypeId, {
195 aliases: ["Cloudflare.Dns.Dnssec"],
196});
197
198/**
199 * Returns true if the given value is a Dnssec resource.
200 */
201export const issec = (value: unknown): value is Dnssec =>
202 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
203
204export const DnssecProvider = () =>
205 Provider.succeed(Dnssec, {
206 stables: [
207 "zoneId",
208 "initialStatus",
209 "initialMultiSigner",
210 "initialPresigned",
211 "initialUseNsec3",
212 ],
213
214 list: Effect.fn(function* () {
215 const { accountId } = yield* yield* CloudflareEnvironment;
216 // No account-wide API for this zone singleton — enumerate every
217 // zone in the account and read its DNSSEC config (every zone has
218 // one). Disabled zones are "not created" for this resource (same
219 // as a cold `read`), so they're skipped; the observed state of an
220 // active zone becomes its captured initial state.
221 const allZones = yield* listAllZones(accountId);
222 const rows = yield* Effect.forEach(
223 allZones.map((zone) => zone.id),
224 (zoneId) =>
225 dns.getDnssec({ zoneId }).pipe(
226 Effect.map((observed) =>
227 statusFamily(observed.status) === "disabled"
228 ? undefined
229 : toAttributes(zoneId, observed, captureInitial(observed)),
230 ),
231 // Plan-gated or partial zones reject the route; skip them.
232 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
233 ),
234 { concurrency: 10 },
235 );
236 return rows.filter((row): row is DnssecAttributes => row !== undefined);
237 }),
238
239 diff: Effect.fn(function* ({ olds = {}, news, output }) {
240 const o = olds as DnssecProps;
241 const n = news as DnssecProps;
242 // zoneId is the resource's identity (DNSSEC is a zone singleton).
243 // It is Input<string>; compare only once both sides are concrete.
244 const oldZoneId =
245 output?.zoneId ?? (typeof o.zoneId === "string" ? o.zoneId : undefined);
246 if (
247 oldZoneId !== undefined &&
248 typeof n.zoneId === "string" &&
249 oldZoneId !== n.zoneId
250 ) {
251 return { action: "replace" } as const;
252 }
253 return undefined;
254 }),
255
256 read: Effect.fn(function* ({ output, olds }) {
257 const zoneId =
258 output?.zoneId ??
259 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
260 if (!zoneId) return undefined;
261 const observed = yield* dns.getDnssec({ zoneId }).pipe(
262 // Zone deleted out-of-band — DNSSEC config is gone with it.
263 Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)),
264 );
265 if (observed === undefined) return undefined;
266 if (output !== undefined) {
267 // Owned path — refresh, keeping the captured pre-management state.
268 return toAttributes(zoneId, observed, {
269 status: output.initialStatus,
270 multiSigner: output.initialMultiSigner,
271 presigned: output.initialPresigned,
272 useNsec3: output.initialUseNsec3,
273 });
274 }
275 // Cold read: DNSSEC disabled means "not created" for this resource.
276 if (statusFamily(observed.status) === "disabled") return undefined;
277 // DNSSEC is enabled but we have no state and no ownership markers
278 // exist — gate takeover behind the adopt policy.
279 return Unowned(toAttributes(zoneId, observed, captureInitial(observed)));
280 }),
281
282 reconcile: Effect.fn(function* ({ news, output }) {
283 // Inputs have been resolved to concrete strings by Plan.
284 const zoneId = news.zoneId as string;
285 const desiredStatus: DnssecDesiredStatus = news.status ?? "active";
286
287 // 1. Observe — DNSSEC config always exists for a live zone.
288 let observed = yield* dns.getDnssec({ zoneId });
289
290 // 2. Capture — the pre-management state, restored on destroy.
291 // `output` (including an adoption read) already carries it;
292 // otherwise this is our first touch.
293 const initial: InitialDnssec =
294 output !== undefined
295 ? {
296 status: output.initialStatus,
297 multiSigner: output.initialMultiSigner,
298 presigned: output.initialPresigned,
299 useNsec3: output.initialUseNsec3,
300 }
301 : captureInitial(observed);
302
303 // 3. Sync — patch only when the observed state differs from the
304 // desired one.
305 if (!matchesDesired(observed, news, desiredStatus)) {
306 observed = yield* dns.patchDnssec({
307 zoneId,
308 status: desiredStatus,
309 dnssecMultiSigner: news.dnssecMultiSigner,
310 dnssecPresigned: news.dnssecPresigned,
311 dnssecUseNsec3: news.dnssecUseNsec3,
312 });
313 }
314
315 // 4. Wait — activation/deactivation is eventually consistent.
316 // Poll (bounded) for the status to reach the desired family;
317 // "active" is satisfied by `pending` since full activation
318 // requires the registrar-side DS record.
319 if (statusFamily(observed.status) !== desiredStatus) {
320 observed = yield* dns.getDnssec({ zoneId }).pipe(
321 Effect.repeat({
322 schedule: Schedule.spaced("2 seconds"),
323 until: (o) => statusFamily(o.status) === desiredStatus,
324 times: 15,
325 }),
326 );
327 }
328
329 return toAttributes(zoneId, observed, initial);
330 }),
331
332 delete: Effect.fn(function* ({ output }) {
333 const zoneId = output.zoneId;
334 // Observe — if the zone itself is gone, so is its DNSSEC config.
335 const observed = yield* dns
336 .getDnssec({ zoneId })
337 .pipe(Effect.catchTag("InvalidRoute", () => Effect.succeed(undefined)));
338 if (observed === undefined) return;
339 if (output.initialStatus === "disabled") {
340 // The zone had DNSSEC off before we managed it — deactivate.
341 // Cloudflare's DELETE endpoint only clears DNSSEC records *after*
342 // signing has been disabled, so deactivation goes through PATCH.
343 // Skip the call when it's already off (idempotent re-delete).
344 if (statusFamily(observed.status) === "disabled") return;
345 yield* dns
346 .patchDnssec({ zoneId, status: "disabled" })
347 .pipe(Effect.catchTag("InvalidRoute", () => Effect.void));
348 return;
349 }
350 // The zone had DNSSEC on before we managed it — restore the
351 // enabled state and the flags it had at capture time.
352 if (
353 statusFamily(observed.status) === "active" &&
354 flag(observed.dnssecMultiSigner) === flag(output.initialMultiSigner) &&
355 flag(observed.dnssecPresigned) === flag(output.initialPresigned) &&
356 flag(observed.dnssecUseNsec3) === flag(output.initialUseNsec3)
357 ) {
358 return;
359 }
360 yield* dns
361 .patchDnssec({
362 zoneId,
363 status: "active",
364 dnssecMultiSigner: output.initialMultiSigner,
365 dnssecPresigned: output.initialPresigned,
366 dnssecUseNsec3: output.initialUseNsec3,
367 })
368 .pipe(Effect.catchTag("InvalidRoute", () => Effect.void));
369 }),
370 });
371
372interface InitialDnssec {
373 readonly status: DnssecDesiredStatus;
374 readonly multiSigner: boolean | undefined;
375 readonly presigned: boolean | undefined;
376 readonly useNsec3: boolean | undefined;
377}
378
379/**
380 * Collapse Cloudflare's five-state live status into the two-state
381 * desired family (`pending` is on its way to `active`,
382 * `pending-disabled` / `error` / unknown collapse to `disabled`).
383 */
384const statusFamily = (
385 status: string | null | undefined,
386): DnssecDesiredStatus =>
387 status === "active" || status === "pending" ? "active" : "disabled";
388
389const flag = (v: boolean | null | undefined): boolean => v ?? false;
390
391const captureInitial = (observed: dns.GetDnssecResponse): InitialDnssec => ({
392 status: statusFamily(observed.status),
393 multiSigner: observed.dnssecMultiSigner ?? undefined,
394 presigned: observed.dnssecPresigned ?? undefined,
395 useNsec3: observed.dnssecUseNsec3 ?? undefined,
396});
397
398const matchesDesired = (
399 observed: dns.GetDnssecResponse,
400 news: DnssecProps,
401 desiredStatus: DnssecDesiredStatus,
402): boolean => {
403 if (statusFamily(observed.status) !== desiredStatus) return false;
404 if (
405 news.dnssecMultiSigner !== undefined &&
406 news.dnssecMultiSigner !== flag(observed.dnssecMultiSigner)
407 ) {
408 return false;
409 }
410 if (
411 news.dnssecPresigned !== undefined &&
412 news.dnssecPresigned !== flag(observed.dnssecPresigned)
413 ) {
414 return false;
415 }
416 if (
417 news.dnssecUseNsec3 !== undefined &&
418 news.dnssecUseNsec3 !== flag(observed.dnssecUseNsec3)
419 ) {
420 return false;
421 }
422 return true;
423};
424
425const undef = <T>(v: T | null | undefined): T | undefined =>
426 v == null ? undefined : v;
427
428const toAttributes = (
429 zoneId: string,
430 observed: dns.GetDnssecResponse | dns.PatchDnssecResponse,
431 initial: InitialDnssec,
432): DnssecAttributes => ({
433 zoneId,
434 status: observed.status ?? "disabled",
435 dnssecMultiSigner: undef(observed.dnssecMultiSigner),
436 dnssecPresigned: undef(observed.dnssecPresigned),
437 dnssecUseNsec3: undef(observed.dnssecUseNsec3),
438 algorithm: undef(observed.algorithm),
439 digest: undef(observed.digest),
440 digestAlgorithm: undef(observed.digestAlgorithm),
441 digestType: undef(observed.digestType),
442 ds: undef(observed.ds),
443 flags: undef(observed.flags),
444 keyTag: undef(observed.keyTag),
445 keyType: undef(observed.keyType),
446 publicKey: undef(observed.publicKey),
447 modifiedOn: undef(observed.modifiedOn),
448 initialStatus: initial.status,
449 initialMultiSigner: initial.multiSigner,
450 initialPresigned: initial.presigned,
451 initialUseNsec3: initial.useNsec3,
452});