1import * as ddos from "@distilled.cloud/cloudflare/ddos-protection";
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 * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.DdosProtection.SynProtectionRule" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Operating mode of an Advanced TCP Protection rule: actively mitigate
18 * (`enabled`), observe only (`monitoring`), or stand down (`disabled`).
19 */
20export type SynProtectionRuleMode = "enabled" | "disabled" | "monitoring";
21
22/**
23 * Where a SYN Protection rule applies: every prefix (`global`), one
24 * Cloudflare region (`region`), or one data center (`datacenter`).
25 */
26export type SynProtectionRuleScope = "global" | "region" | "datacenter";
27
28/**
29 * Sensitivity of the SYN Protection thresholds.
30 */
31export type SynProtectionRuleSensitivity = "low" | "medium" | "high";
32
33/**
34 * How SYN Protection mitigates matched floods.
35 */
36export type SynProtectionMitigationType = "challenge" | "retransmit";
37
38export interface SynProtectionRuleProps {
39 /**
40 * The scope of the rule: `global`, `region`, or `datacenter`.
41 *
42 * Immutable — the API only patches mode/sensitivities, so changing the
43 * scope triggers a replacement.
44 */
45 scope: SynProtectionRuleScope;
46 /**
47 * The name of the rule, relative to `scope`: for `global` scope the name
48 * is `global`; for `region`/`datacenter` scope it is the region or data
49 * center name (e.g. `WEUR`, `SJC`).
50 *
51 * Immutable — changing the name triggers a replacement.
52 * @default "global"
53 */
54 name?: string;
55 /**
56 * Operating mode of the rule. Mutable — patched in place.
57 */
58 mode: SynProtectionRuleMode;
59 /**
60 * The burst sensitivity. Mutable — patched in place.
61 */
62 burstSensitivity: SynProtectionRuleSensitivity;
63 /**
64 * The rate sensitivity. Mutable — patched in place.
65 */
66 rateSensitivity: SynProtectionRuleSensitivity;
67 /**
68 * The type of mitigation applied to matched SYN floods. Mutable —
69 * patched in place.
70 * @default "challenge"
71 */
72 mitigationType?: SynProtectionMitigationType;
73}
74
75export interface SynProtectionRuleAttributes {
76 /** Cloudflare-assigned identifier of the SYN Protection rule. */
77 ruleId: string;
78 /** The Cloudflare account the rule belongs to. */
79 accountId: string;
80 /** The scope of the rule. */
81 scope: SynProtectionRuleScope;
82 /** The name of the rule, relative to its scope. */
83 name: string;
84 /** Operating mode of the rule. */
85 mode: SynProtectionRuleMode;
86 /** The burst sensitivity. */
87 burstSensitivity: SynProtectionRuleSensitivity;
88 /** The rate sensitivity. */
89 rateSensitivity: SynProtectionRuleSensitivity;
90 /** The type of mitigation applied to matched SYN floods. */
91 mitigationType: SynProtectionMitigationType;
92 /** ISO8601 creation timestamp. */
93 createdOn: string;
94 /** ISO8601 last-modified timestamp. */
95 modifiedOn: string;
96}
97
98export type SynProtectionRule = Resource<
99 TypeId,
100 SynProtectionRuleProps,
101 SynProtectionRuleAttributes,
102 never,
103 Providers
104>;
105
106/**
107 * An Advanced TCP Protection SYN flood rule (Magic Transit).
108 *
109 * Rules tune how Cloudflare mitigates SYN floods on Magic Transit prefixes,
110 * per scope (`global`, a region, or a data center). The rule's identity is
111 * its `scope` + `name` pair — only `mode`, sensitivities, and
112 * `mitigationType` are mutable in place.
113 *
114 * Requires the **Magic Transit / Advanced TCP Protection** entitlement; on
115 * accounts without it every API call fails with the typed
116 * `AdvancedTcpProtectionNotEntitled` error.
117 *
118 * Safety: rules carry no ownership markers. When there is no prior state,
119 * `read` scans for an existing rule with the same scope + name and reports
120 * it as `Unowned`, so the engine refuses to take it over unless `--adopt`
121 * (or `adopt(true)`) is set.
122 * ### Creating a rule
123 * **Example:** Global SYN protection in monitoring mode
124 * ```typescript
125 * const rule = yield* Cloudflare.DdosProtection.SynProtectionRule("GlobalSyn", {
126 * scope: "global",
127 * mode: "monitoring",
128 * burstSensitivity: "medium",
129 * rateSensitivity: "medium",
130 * });
131 * ```
132 *
133 * **Example:** Data-center scoped rule with retransmit mitigation
134 * ```typescript
135 * yield* Cloudflare.DdosProtection.SynProtectionRule("SjcSyn", {
136 * scope: "datacenter",
137 * name: "SJC",
138 * mode: "enabled",
139 * burstSensitivity: "high",
140 * rateSensitivity: "high",
141 * mitigationType: "retransmit",
142 * });
143 * ```
144 *
145 * @see https://developers.cloudflare.com/ddos-protection/advanced-ddos-systems/overview/advanced-tcp-protection/
146 *
147 * @resource
148 * @product DDoS Protection
149 * @category Network
150 */
151export const SynProtectionRule = Resource<SynProtectionRule>(TypeId);
152
153/**
154 * Returns true if the given value is a SynProtectionRule resource.
155 */
156export const isSynProtectionRule = (
157 value: unknown,
158): value is SynProtectionRule =>
159 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
160
161export const SynProtectionRuleProvider = () =>
162 Provider.succeed(SynProtectionRule, {
163 stables: ["ruleId", "accountId", "scope", "name", "createdOn"],
164
165 // Account-scoped collection: paginate every rule in the ambient
166 // account. Accounts without the Advanced TCP Protection entitlement (or
167 // lacking read permission) yield no rules — treat the typed rejection as
168 // an empty enumeration rather than an error.
169 list: Effect.fn(function* () {
170 const { accountId } = yield* yield* CloudflareEnvironment;
171 return yield* ddos.listAdvancedTcpProtectionSynProtectionRules
172 .pages({ accountId })
173 .pipe(
174 Stream.runCollect,
175 Effect.map((chunk) =>
176 Array.from(chunk).flatMap((page) =>
177 (page.result ?? []).map((rule) => toAttributes(rule, accountId)),
178 ),
179 ),
180 Effect.catchTags({
181 AdvancedTcpProtectionNotEntitled: () => Effect.succeed([]),
182 Forbidden: () => Effect.succeed([]),
183 }),
184 );
185 }),
186
187 diff: Effect.fn(function* ({ olds, news }) {
188 if (olds === undefined) return undefined;
189 // `news` runs at plan time and may still carry unresolved
190 // expressions — bail out and let the engine apply default logic.
191 if (!isResolved(news)) return undefined;
192 // The API only patches mode/sensitivities/mitigationType — the
193 // scope + name pair is the rule's identity and cannot change.
194 if (olds.scope !== news.scope || ruleName(olds) !== ruleName(news)) {
195 return { action: "replace" } as const;
196 }
197 return undefined;
198 }),
199
200 read: Effect.fn(function* ({ output, olds }) {
201 const { accountId } = yield* yield* CloudflareEnvironment;
202 const acct = output?.accountId ?? accountId;
203
204 // Owned path: refresh by our persisted rule id.
205 if (output?.ruleId) {
206 const observed = yield* getRule(acct, output.ruleId);
207 if (observed) return toAttributes(observed, acct);
208 }
209
210 // Adoption path: a rule for this scope + name may already exist.
211 // Rules carry no ownership markers, so brand the match `Unowned` —
212 // the engine refuses to take over unless `adopt` is set.
213 const identity = output ?? olds;
214 if (identity?.scope) {
215 const observed = yield* findByScopeAndName(
216 acct,
217 identity.scope,
218 ruleName(identity),
219 );
220 if (observed) return Unowned(toAttributes(observed, acct));
221 }
222 return undefined;
223 }),
224
225 reconcile: Effect.fn(function* ({ news, output }) {
226 const { accountId } = yield* yield* CloudflareEnvironment;
227 const name = ruleName(news);
228
229 // 1. Observe — the rule id cached on `output` is a hint, not a
230 // guarantee: a missing rule falls through to the scope + name
231 // scan and then to create.
232 let observed = output?.ruleId
233 ? yield* getRule(accountId, output.ruleId)
234 : undefined;
235
236 // 2. Fall back to scanning for the scope + name match (ownership was
237 // already gated by `read` reporting existing rules as Unowned).
238 if (!observed) {
239 observed = yield* findByScopeAndName(accountId, news.scope, name);
240 }
241
242 // 3. Ensure — create when missing.
243 if (!observed) {
244 observed = yield* ddos.createAdvancedTcpProtectionSynProtectionRule({
245 accountId,
246 scope: news.scope,
247 name,
248 mode: news.mode,
249 burstSensitivity: news.burstSensitivity,
250 rateSensitivity: news.rateSensitivity,
251 mitigationType: news.mitigationType,
252 });
253 }
254
255 // 4. Sync — diff observed mutable aspects against desired; skip the
256 // patch entirely on a no-op.
257 const dirty =
258 observed.mode !== news.mode ||
259 observed.burstSensitivity !== news.burstSensitivity ||
260 observed.rateSensitivity !== news.rateSensitivity ||
261 (news.mitigationType !== undefined &&
262 observed.mitigationType !== news.mitigationType);
263 if (dirty) {
264 observed = yield* ddos.patchAdvancedTcpProtectionSynProtectionRuleItem({
265 accountId,
266 ruleId: observed.id,
267 mode: news.mode,
268 burstSensitivity: news.burstSensitivity,
269 rateSensitivity: news.rateSensitivity,
270 mitigationType: news.mitigationType,
271 });
272 }
273
274 return toAttributes(observed, accountId);
275 }),
276
277 delete: Effect.fn(function* ({ output }) {
278 yield* ddos
279 .deleteAdvancedTcpProtectionSynProtectionRuleItem({
280 accountId: output.accountId,
281 ruleId: output.ruleId,
282 })
283 .pipe(Effect.catchTag("SynProtectionRuleNotFound", () => Effect.void));
284 }),
285 });
286
287type ObservedRule = ddos.GetAdvancedTcpProtectionSynProtectionRuleItemResponse;
288
289const ruleName = (props: {
290 scope: SynProtectionRuleScope | string;
291 name?: string;
292}) => props.name ?? "global";
293
294/**
295 * Read a rule by id, mapping "gone" (`SynProtectionRuleNotFound`, HTTP 404)
296 * to `undefined`.
297 */
298const getRule = (accountId: string, ruleId: string) =>
299 ddos
300 .getAdvancedTcpProtectionSynProtectionRuleItem({ accountId, ruleId })
301 .pipe(
302 Effect.map((rule): ObservedRule | undefined => rule),
303 Effect.catchTag("SynProtectionRuleNotFound", () =>
304 Effect.succeed(undefined),
305 ),
306 );
307
308/**
309 * Find a rule by its scope + name identity. If several rules carry the same
310 * pair, pick the oldest for determinism.
311 */
312const findByScopeAndName = (accountId: string, scope: string, name: string) =>
313 ddos.listAdvancedTcpProtectionSynProtectionRules.items({ accountId }).pipe(
314 Stream.runCollect,
315 Effect.map((chunk) =>
316 Array.from(chunk)
317 .filter((rule) => rule.scope === scope && rule.name === name)
318 .sort((a, b) => a.createdOn.localeCompare(b.createdOn))
319 .at(0),
320 ),
321 );
322
323const toAttributes = (
324 rule: ObservedRule,
325 accountId: string,
326): SynProtectionRuleAttributes => ({
327 ruleId: rule.id,
328 accountId,
329 // Distilled widens generated string enums to plain strings.
330 scope: rule.scope as SynProtectionRuleScope,
331 name: rule.name,
332 mode: rule.mode as SynProtectionRuleMode,
333 burstSensitivity: rule.burstSensitivity as SynProtectionRuleSensitivity,
334 rateSensitivity: rule.rateSensitivity as SynProtectionRuleSensitivity,
335 mitigationType: rule.mitigationType as SynProtectionMitigationType,
336 createdOn: rule.createdOn,
337 modifiedOn: rule.modifiedOn,
338});