1import * as dnsFirewall from "@distilled.cloud/cloudflare/dns-firewall";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.DNS.Firewall" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Attack mitigation settings for a DNS Firewall cluster.
19 */
20export type AttackMitigation = {
21 /**
22 * When enabled, automatically mitigate random-prefix attacks to protect
23 * upstream DNS servers.
24 * @default false
25 */
26 enabled?: boolean;
27 /**
28 * Only mitigate attacks when upstream servers seem unhealthy.
29 * @default false
30 */
31 onlyWhenUpstreamUnhealthy?: boolean;
32};
33
34export type FirewallProps = {
35 /**
36 * DNS Firewall cluster name. Changing the name triggers a replacement —
37 * the name is the identity used for cold-state recovery. If omitted, a
38 * unique name is generated from the app, stage, and logical ID.
39 * @default ${app}-${stage}-${id}
40 */
41 name?: string;
42 /**
43 * Upstream DNS server IPs the cluster forwards queries to. At least one
44 * is required.
45 */
46 upstreamIps: string[];
47 /**
48 * Attack mitigation settings.
49 * @default disabled
50 */
51 attackMitigation?: AttackMitigation;
52 /**
53 * Whether to refuse to answer queries for the ANY type.
54 * @default false
55 */
56 deprecateAnyRequests?: boolean;
57 /**
58 * Whether to forward the client IP (resolver) subnet if no EDNS Client
59 * Subnet is sent.
60 * @default false
61 */
62 ecsFallback?: boolean;
63 /**
64 * Upper bound (in seconds) on how long responses are cached, regardless
65 * of the TTL received from the upstream nameservers.
66 * @default 900
67 */
68 maximumCacheTtl?: number;
69 /**
70 * Lower bound (in seconds) on how long responses are cached, regardless
71 * of the TTL received from the upstream nameservers.
72 * @default 60
73 */
74 minimumCacheTtl?: number;
75 /**
76 * How long (in seconds) DNS Firewall caches negative responses (e.g.
77 * NXDOMAIN) from the upstream servers. `null` uses Cloudflare's default
78 * behavior.
79 * @default null
80 */
81 negativeCacheTtl?: number | null;
82 /**
83 * Ratelimit in queries per second per datacenter, applied to queries
84 * sent to the upstream nameservers. `null` disables the limit.
85 * @default null
86 */
87 ratelimit?: number | null;
88 /**
89 * Number of retries for fetching DNS responses from upstream nameservers
90 * (not counting the initial attempt).
91 * @default 2
92 */
93 retries?: number;
94 /**
95 * Reverse DNS (PTR) mappings for the cluster's assigned IPs — a map of
96 * cluster IP address to PTR record content. Only the entries listed here
97 * are managed: entries are upserted, and removing an entry from this map
98 * leaves the PTR in place (clear it explicitly with an empty string).
99 */
100 reverseDns?: Record<string, string>;
101};
102
103export type FirewallAttributes = {
104 /**
105 * DNS Firewall cluster identifier (UUID).
106 */
107 dnsFirewallId: string;
108 /**
109 * The Cloudflare account the cluster belongs to.
110 */
111 accountId: string;
112 /**
113 * DNS Firewall cluster name.
114 */
115 name: string;
116 /**
117 * The Cloudflare-assigned anycast IPs of the cluster. Point NS glue
118 * records at these.
119 */
120 dnsFirewallIps: string[];
121 /**
122 * Upstream DNS server IPs the cluster forwards queries to.
123 */
124 upstreamIps: string[];
125 /**
126 * Attack mitigation settings.
127 */
128 attackMitigation: {
129 enabled: boolean;
130 onlyWhenUpstreamUnhealthy: boolean;
131 };
132 /**
133 * Whether queries for the ANY type are refused.
134 */
135 deprecateAnyRequests: boolean;
136 /**
137 * Whether the client IP (resolver) subnet is forwarded when no EDNS
138 * Client Subnet is sent.
139 */
140 ecsFallback: boolean;
141 /**
142 * Upper bound (in seconds) on response cache duration.
143 */
144 maximumCacheTtl: number;
145 /**
146 * Lower bound (in seconds) on response cache duration.
147 */
148 minimumCacheTtl: number;
149 /**
150 * Negative response cache duration (in seconds), or `null` for
151 * Cloudflare's default behavior.
152 */
153 negativeCacheTtl: number | null;
154 /**
155 * Ratelimit in queries per second per datacenter, or `null` when
156 * disabled.
157 */
158 ratelimit: number | null;
159 /**
160 * Number of retries for fetching DNS responses from upstream
161 * nameservers.
162 */
163 retries: number;
164 /**
165 * Reverse DNS (PTR) mappings managed on the cluster, when the
166 * `reverseDns` prop is set.
167 */
168 reverseDns: Record<string, string> | undefined;
169 /**
170 * Last modification time of the cluster.
171 */
172 modifiedOn: string;
173};
174
175export type Firewall = Resource<
176 TypeId,
177 FirewallProps,
178 FirewallAttributes,
179 never,
180 Providers
181>;
182
183/**
184 * A Cloudflare DNS Firewall cluster.
185 *
186 * DNS Firewall sits in front of your authoritative DNS infrastructure,
187 * caching responses on Cloudflare's anycast network and shielding the
188 * upstream nameservers from attack traffic. Creating a cluster assigns a
189 * set of Cloudflare anycast IPs (`dnsFirewallIps`) that you point NS glue
190 * records at; queries hitting those IPs are answered from cache or
191 * forwarded to your `upstreamIps`.
192 *
193 * DNS Firewall is a paid add-on (typically Enterprise / contract). On
194 * accounts without the entitlement, creation fails with the typed
195 * `DnsFirewallNotEntitled` error (Cloudflare error code 10101).
196 *
197 * All settings are mutable in place; only `name` (the cold-state recovery
198 * identity) triggers a replacement.
199 * ### Creating a Cluster
200 * **Example:** Basic cluster
201 * ```typescript
202 * const cluster = yield* Cloudflare.DNS.Firewall("dns-shield", {
203 * upstreamIps: ["192.0.2.1", "192.0.2.2"],
204 * });
205 * // Point NS glue records at the assigned anycast IPs:
206 * const ips = cluster.dnsFirewallIps;
207 * ```
208 *
209 * **Example:** Tuned caching and attack mitigation
210 * ```typescript
211 * const cluster = yield* Cloudflare.DNS.Firewall("dns-shield", {
212 * upstreamIps: ["192.0.2.1"],
213 * minimumCacheTtl: 120,
214 * maximumCacheTtl: 3600,
215 * negativeCacheTtl: 300,
216 * ratelimit: 600,
217 * retries: 2,
218 * attackMitigation: {
219 * enabled: true,
220 * onlyWhenUpstreamUnhealthy: true,
221 * },
222 * });
223 * ```
224 *
225 * ### Reverse DNS
226 * **Example:** Managing PTR records for cluster IPs
227 * ```typescript
228 * const cluster = yield* Cloudflare.DNS.Firewall("dns-shield", {
229 * upstreamIps: ["192.0.2.1"],
230 * reverseDns: {
231 * "203.0.113.1": "ns1.example.com",
232 * },
233 * });
234 * ```
235 *
236 * @see https://developers.cloudflare.com/dns/dns-firewall/
237 *
238 * @resource
239 * @product DNS Firewall
240 * @category Domains & DNS
241 */
242export const Firewall = Resource<Firewall>(TypeId, {
243 aliases: ["Cloudflare.DnsFirewall"],
244});
245
246/**
247 * Returns true if the given value is a DnsFirewall resource.
248 */
249export const isFirewall = (value: unknown): value is Firewall =>
250 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
251
252export const FirewallProvider = () =>
253 Provider.succeed(Firewall, {
254 stables: ["dnsFirewallId", "accountId", "dnsFirewallIps"],
255 diff: Effect.fn(function* ({ id, olds, news, output }) {
256 const { accountId } = yield* yield* CloudflareEnvironment;
257 if (!isResolved(news)) return undefined;
258 if ((output?.accountId ?? accountId) !== accountId) {
259 return { action: "replace" } as const;
260 }
261 // The name is the cold-state recovery identity — renames replace.
262 const oldName = output?.name ?? (yield* createClusterName(id, olds.name));
263 // Auto-generated names are engine-owned: the deployed name stays
264 // authoritative even if the generator would name this id differently
265 // today. Only an explicit user-provided name can force a replace.
266 const name = news.name ?? oldName;
267 if (name !== oldName) {
268 return { action: "replace" } as const;
269 }
270 return undefined;
271 }),
272 read: Effect.fn(function* ({ id, output, olds }) {
273 const { accountId } = yield* yield* CloudflareEnvironment;
274 const acct = output?.accountId ?? accountId;
275
276 if (output?.dnsFirewallId) {
277 const observed = yield* getCluster(acct, output.dnsFirewallId);
278 if (!observed) return undefined;
279 const reverseDns =
280 olds?.reverseDns !== undefined
281 ? yield* getReverseDns(acct, observed.id)
282 : undefined;
283 return toAttributes(observed, acct, reverseDns);
284 }
285
286 // Cold read — recover from lost state by matching the deterministic
287 // physical name. Cluster names are not guaranteed unique and carry
288 // no ownership markers, so report the match as `Unowned`: the
289 // engine refuses to take it over unless `adopt` is set.
290 const name = yield* createClusterName(id, olds?.name);
291 const match = yield* findByName(acct, name);
292 if (!match) return undefined;
293 const reverseDns =
294 olds?.reverseDns !== undefined
295 ? yield* getReverseDns(acct, match.id)
296 : undefined;
297 return Unowned(toAttributes(match, acct, reverseDns));
298 }),
299 list: Effect.fn(function* () {
300 // Account-scoped collection: exhaustively paginate the DNS Firewall
301 // list for the ambient account. Each list item already carries the
302 // full cluster shape (identical to `getDnsFirewall`), so it maps
303 // directly into the `read` Attributes shape — no per-item hydration
304 // needed. `reverseDns` is left `undefined` (the same shape `read`
305 // produces when no managed PTR map is tracked).
306 const { accountId } = yield* yield* CloudflareEnvironment;
307 return yield* dnsFirewall.listDnsFirewalls.pages({ accountId }).pipe(
308 Stream.runCollect,
309 Effect.map((chunk) =>
310 Array.from(chunk).flatMap((page) =>
311 page.result.map((cluster) =>
312 toAttributes({ ...cluster, accountId }, accountId, undefined),
313 ),
314 ),
315 ),
316 );
317 }),
318 reconcile: Effect.fn(function* ({ id, news, output }) {
319 const { accountId } = yield* yield* CloudflareEnvironment;
320 // Prefer the deployed name: regenerating would rename the deployed
321 // cluster if the generator's output for this id ever drifts.
322 const name = output?.name ?? (yield* createClusterName(id, news.name));
323
324 // Observe — the id cached on `output` is a hint, not a guarantee: a
325 // missing cluster falls through to "missing" and we recreate.
326 const observed = output?.dnsFirewallId
327 ? yield* getCluster(output.accountId ?? accountId, output.dnsFirewallId)
328 : undefined;
329
330 let synced: ObservedCluster;
331 if (!observed) {
332 // Ensure — greenfield (or out-of-band delete): create with the
333 // full desired body. Names are not unique on Cloudflare's side,
334 // so there is no AlreadyExists race to tolerate.
335 const created = yield* dnsFirewall.createDnsFirewall({
336 accountId,
337 name,
338 upstreamIps: news.upstreamIps,
339 attackMitigation: news.attackMitigation,
340 deprecateAnyRequests: news.deprecateAnyRequests,
341 ecsFallback: news.ecsFallback,
342 maximumCacheTtl: news.maximumCacheTtl,
343 minimumCacheTtl: news.minimumCacheTtl,
344 negativeCacheTtl: news.negativeCacheTtl,
345 ratelimit: news.ratelimit,
346 retries: news.retries,
347 });
348 synced = { ...created, accountId };
349 } else {
350 // Sync — diff observed cloud state against the desired settings
351 // (props with their documented defaults applied) and PATCH only
352 // when something actually differs.
353 const desired = {
354 name,
355 upstreamIps: news.upstreamIps,
356 attackMitigation: {
357 enabled: news.attackMitigation?.enabled ?? false,
358 onlyWhenUpstreamUnhealthy:
359 news.attackMitigation?.onlyWhenUpstreamUnhealthy ?? false,
360 },
361 deprecateAnyRequests: news.deprecateAnyRequests ?? false,
362 ecsFallback: news.ecsFallback ?? false,
363 maximumCacheTtl: news.maximumCacheTtl ?? 900,
364 minimumCacheTtl: news.minimumCacheTtl ?? 60,
365 negativeCacheTtl: news.negativeCacheTtl ?? null,
366 ratelimit: news.ratelimit ?? null,
367 retries: news.retries ?? 2,
368 };
369 const observedMitigation = normalizeMitigation(
370 observed.attackMitigation,
371 );
372 const dirty =
373 observed.name !== desired.name ||
374 !sameIps(observed.upstreamIps, desired.upstreamIps) ||
375 observedMitigation.enabled !== desired.attackMitigation.enabled ||
376 observedMitigation.onlyWhenUpstreamUnhealthy !==
377 desired.attackMitigation.onlyWhenUpstreamUnhealthy ||
378 observed.deprecateAnyRequests !== desired.deprecateAnyRequests ||
379 observed.ecsFallback !== desired.ecsFallback ||
380 observed.maximumCacheTtl !== desired.maximumCacheTtl ||
381 observed.minimumCacheTtl !== desired.minimumCacheTtl ||
382 observed.negativeCacheTtl !== desired.negativeCacheTtl ||
383 observed.ratelimit !== desired.ratelimit ||
384 observed.retries !== desired.retries;
385
386 synced = dirty
387 ? {
388 ...(yield* dnsFirewall.patchDnsFirewall({
389 accountId: observed.accountId,
390 dnsFirewallId: observed.id,
391 ...desired,
392 })),
393 accountId: observed.accountId,
394 }
395 : observed;
396 }
397
398 // Sync reverse DNS (PTR) entries when managed. Only the entries in
399 // the desired map are reconciled — observed entries outside the map
400 // are left untouched.
401 let reverseDns: Record<string, string> | undefined;
402 if (news.reverseDns !== undefined) {
403 const observedPtr = yield* getReverseDns(synced.accountId, synced.id);
404 const dirtyPtr = Object.entries(news.reverseDns).some(
405 ([ip, ptr]) => observedPtr[ip] !== ptr,
406 );
407 if (dirtyPtr) {
408 yield* dnsFirewall.patchReverseDn({
409 accountId: synced.accountId,
410 dnsFirewallId: synced.id,
411 ptr: news.reverseDns,
412 });
413 }
414 reverseDns = { ...observedPtr, ...news.reverseDns };
415 }
416
417 return toAttributes(synced, synced.accountId, reverseDns);
418 }),
419 delete: Effect.fn(function* ({ output }) {
420 yield* dnsFirewall
421 .deleteDnsFirewall({
422 accountId: output.accountId,
423 dnsFirewallId: output.dnsFirewallId,
424 })
425 .pipe(Effect.catchTag("DnsFirewallNotFound", () => Effect.void));
426 }),
427 });
428
429type ObservedCluster = dnsFirewall.GetDnsFirewallResponse & {
430 accountId: string;
431};
432
433/**
434 * Read a cluster by id, mapping "gone" (`DnsFirewallNotFound`, Cloudflare
435 * error code 11001) to `undefined`.
436 */
437const getCluster = (accountId: string, dnsFirewallId: string) =>
438 dnsFirewall.getDnsFirewall({ accountId, dnsFirewallId }).pipe(
439 Effect.map((c): ObservedCluster => ({ ...c, accountId })),
440 Effect.catchTag("DnsFirewallNotFound", () => Effect.succeed(undefined)),
441 );
442
443/**
444 * Read the reverse DNS (PTR) map of a cluster, narrowing the distilled
445 * `Record<string, unknown>` values to strings.
446 */
447const getReverseDns = (accountId: string, dnsFirewallId: string) =>
448 dnsFirewall
449 .getReverseDn({ accountId, dnsFirewallId })
450 .pipe(
451 Effect.map((r) =>
452 Object.fromEntries(
453 Object.entries(r.ptr).flatMap(([ip, ptr]) =>
454 typeof ptr === "string" && ptr !== "" ? [[ip, ptr]] : [],
455 ),
456 ),
457 ),
458 );
459
460/**
461 * Find a cluster by exact name. The list API has no name filter, so scan
462 * the (paginated) account list client-side. If several clusters carry the
463 * same name, pick the lexicographically smallest id for determinism.
464 */
465const findByName = (accountId: string, name: string) =>
466 dnsFirewall.listDnsFirewalls.items({ accountId }).pipe(
467 Stream.filter((c) => c.name === name),
468 Stream.runCollect,
469 Effect.map((chunk) =>
470 Array.from(chunk)
471 .sort((a, b) => a.id.localeCompare(b.id))
472 .at(0),
473 ),
474 Effect.map((match) =>
475 match ? ({ ...match, accountId } satisfies ObservedCluster) : undefined,
476 ),
477 );
478
479const createClusterName = (id: string, name: string | undefined) =>
480 Effect.gen(function* () {
481 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
482 });
483
484const normalizeMitigation = (
485 mitigation: dnsFirewall.GetDnsFirewallResponse["attackMitigation"],
486) => ({
487 enabled: mitigation?.enabled ?? false,
488 onlyWhenUpstreamUnhealthy: mitigation?.onlyWhenUpstreamUnhealthy ?? false,
489});
490
491const sameIps = (observed: readonly string[], desired: readonly string[]) =>
492 observed.length === desired.length &&
493 [...observed].sort().join(",") === [...desired].sort().join(",");
494
495const toAttributes = (
496 cluster: ObservedCluster,
497 accountId: string,
498 reverseDns: Record<string, string> | undefined,
499): FirewallAttributes => ({
500 dnsFirewallId: cluster.id,
501 accountId,
502 name: cluster.name,
503 dnsFirewallIps: [...cluster.dnsFirewallIps],
504 upstreamIps: [...cluster.upstreamIps],
505 attackMitigation: normalizeMitigation(cluster.attackMitigation),
506 deprecateAnyRequests: cluster.deprecateAnyRequests,
507 ecsFallback: cluster.ecsFallback,
508 maximumCacheTtl: cluster.maximumCacheTtl,
509 minimumCacheTtl: cluster.minimumCacheTtl,
510 negativeCacheTtl: cluster.negativeCacheTtl,
511 ratelimit: cluster.ratelimit,
512 retries: cluster.retries,
513 reverseDns,
514 modifiedOn: cluster.modifiedOn,
515});