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