| 1 | import * as mnm from "@distilled.cloud/cloudflare/magic-network-monitoring"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | |
| 7 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 8 | import { isResolved } from "../../Diff.ts"; |
| 9 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 10 | import * as Provider from "../../Provider.ts"; |
| 11 | import { Resource } from "../../Resource.ts"; |
| 12 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 13 | import type { Providers } from "../Providers.ts"; |
| 14 | |
| 15 | const TypeId = "Cloudflare.MagicNetworkMonitoring.Rule" as const; |
| 16 | type TypeId = typeof TypeId; |
| 17 | |
| 18 | /** |
| 19 | * MNM rule type: `threshold` (static bits/packets-per-second limits), |
| 20 | * `zscore` (anomaly detection), or `advanced_ddos` (requires Magic Transit). |
| 21 | */ |
| 22 | export type RuleType = "threshold" | "zscore" | "advanced_ddos"; |
| 23 | |
| 24 | /** |
| 25 | * How long a threshold must be exceeded before an alert fires. |
| 26 | */ |
| 27 | export type RuleDuration = |
| 28 | | "1m" |
| 29 | | "5m" |
| 30 | | "10m" |
| 31 | | "15m" |
| 32 | | "20m" |
| 33 | | "30m" |
| 34 | | "45m" |
| 35 | | "60m"; |
| 36 | |
| 37 | export interface RuleProps { |
| 38 | /** |
| 39 | * The Cloudflare account the rule belongs to. Defaults to the profile's |
| 40 | * account. Pass the owning MNM Config's `accountId` output to sequence |
| 41 | * the rule after the configuration (rules cannot be created until the |
| 42 | * account's MNM configuration exists). |
| 43 | */ |
| 44 | accountId?: string; |
| 45 | /** |
| 46 | * The name of the rule. Must be unique within the account. Supports |
| 47 | * `A-Z`, `a-z`, `0-9`, underscore, dash, period, and tilde — no spaces. |
| 48 | * Max 256 characters. If omitted, a unique name is generated from the |
| 49 | * app, stage, and logical ID. Mutable in place. |
| 50 | * @default ${app}-${stage}-${id} |
| 51 | */ |
| 52 | name?: string; |
| 53 | /** |
| 54 | * MNM rule type. Immutable — the rule's alerting semantics differ |
| 55 | * entirely per type, so changing it triggers a replacement. |
| 56 | */ |
| 57 | type: RuleType; |
| 58 | /** |
| 59 | * IPv4 CIDR prefixes the rule monitors. Mutable in place. |
| 60 | */ |
| 61 | prefixes: string[]; |
| 62 | /** |
| 63 | * Toggle on to have Cloudflare automatically advertise the rule's |
| 64 | * prefixes via Magic Transit when the rule triggers. Requires Magic |
| 65 | * Transit. |
| 66 | * @default false |
| 67 | */ |
| 68 | automaticAdvertisement?: boolean; |
| 69 | /** |
| 70 | * Bits per second threshold (threshold rules). When exceeded for the |
| 71 | * set duration, an alert notification is sent. Minimum of 1. |
| 72 | */ |
| 73 | bandwidthThreshold?: number; |
| 74 | /** |
| 75 | * Packets per second threshold (threshold rules). When exceeded for the |
| 76 | * set duration, an alert notification is sent. Minimum of 1. |
| 77 | */ |
| 78 | packetThreshold?: number; |
| 79 | /** |
| 80 | * How long the threshold must be exceeded before an alert fires. |
| 81 | * @default "1m" |
| 82 | */ |
| 83 | duration?: RuleDuration; |
| 84 | /** |
| 85 | * Sensitivity of the anomaly detection (zscore rules). |
| 86 | */ |
| 87 | zscoreSensitivity?: "low" | "medium" | "high"; |
| 88 | /** |
| 89 | * Target of the zscore rule analysis (zscore rules). |
| 90 | */ |
| 91 | zscoreTarget?: "bits" | "packets"; |
| 92 | /** |
| 93 | * Prefix match type applied for prefix auto-advertisement |
| 94 | * (advanced_ddos rules). |
| 95 | */ |
| 96 | prefixMatch?: "exact" | "subnet" | "supernet"; |
| 97 | } |
| 98 | |
| 99 | export interface RuleAttributes { |
| 100 | /** Cloudflare-assigned identifier of the rule. */ |
| 101 | ruleId: string; |
| 102 | /** The Cloudflare account the rule belongs to. */ |
| 103 | accountId: string; |
| 104 | /** The rule's unique name. */ |
| 105 | name: string; |
| 106 | /** MNM rule type. */ |
| 107 | type: RuleType; |
| 108 | /** IPv4 CIDR prefixes the rule monitors. */ |
| 109 | prefixes: string[]; |
| 110 | /** Whether prefixes are auto-advertised via Magic Transit on trigger. */ |
| 111 | automaticAdvertisement: boolean; |
| 112 | /** Bits per second threshold, if set. */ |
| 113 | bandwidthThreshold: number | undefined; |
| 114 | /** Packets per second threshold, if set. */ |
| 115 | packetThreshold: number | undefined; |
| 116 | /** |
| 117 | * Alert duration as reported by Cloudflare (normalized, e.g. `1m0s`). |
| 118 | */ |
| 119 | duration: string | undefined; |
| 120 | /** Zscore sensitivity, if set. */ |
| 121 | zscoreSensitivity: "low" | "medium" | "high" | (string & {}) | undefined; |
| 122 | /** Zscore target, if set. */ |
| 123 | zscoreTarget: "bits" | "packets" | (string & {}) | undefined; |
| 124 | /** Prefix match type, if set. */ |
| 125 | prefixMatch: "exact" | "subnet" | "supernet" | (string & {}) | undefined; |
| 126 | } |
| 127 | |
| 128 | export type Rule = Resource< |
| 129 | TypeId, |
| 130 | RuleProps, |
| 131 | RuleAttributes, |
| 132 | never, |
| 133 | Providers |
| 134 | >; |
| 135 | |
| 136 | /** |
| 137 | * A Magic Network Monitoring (MNM) rule — alerts when traffic to a set of |
| 138 | * IPv4 prefixes exceeds a static threshold (`threshold`), deviates from the |
| 139 | * learned baseline (`zscore`), or matches advanced DDoS criteria |
| 140 | * (`advanced_ddos`, Magic Transit only). |
| 141 | * |
| 142 | * Rules require the account's MNM configuration to exist first — pass the |
| 143 | * Config resource's `accountId` output as this rule's `accountId` to |
| 144 | * sequence the deployment. Rule names are unique per account; the rule |
| 145 | * `type` is immutable and changing it triggers a replacement. |
| 146 | * ### Threshold rules |
| 147 | * **Example:** Alert when bandwidth exceeds 1 Mbps for 5 minutes |
| 148 | * ```typescript |
| 149 | * const config = yield* Cloudflare.MagicNetworkMonitoring.Config("Mnm", { |
| 150 | * name: "my-network", |
| 151 | * defaultSampling: 1, |
| 152 | * }); |
| 153 | * yield* Cloudflare.MagicNetworkMonitoring.Rule("BandwidthAlert", { |
| 154 | * accountId: config.accountId, |
| 155 | * type: "threshold", |
| 156 | * prefixes: ["10.0.0.0/24"], |
| 157 | * bandwidthThreshold: 1_000_000, |
| 158 | * duration: "5m", |
| 159 | * }); |
| 160 | * ``` |
| 161 | * |
| 162 | * **Example:** Packet-rate alert |
| 163 | * ```typescript |
| 164 | * yield* Cloudflare.MagicNetworkMonitoring.Rule("PacketAlert", { |
| 165 | * accountId: config.accountId, |
| 166 | * type: "threshold", |
| 167 | * prefixes: ["10.0.1.0/24"], |
| 168 | * packetThreshold: 10_000, |
| 169 | * }); |
| 170 | * ``` |
| 171 | * |
| 172 | * ### Anomaly detection |
| 173 | * **Example:** Zscore rule on bits |
| 174 | * ```typescript |
| 175 | * yield* Cloudflare.MagicNetworkMonitoring.Rule("AnomalyAlert", { |
| 176 | * accountId: config.accountId, |
| 177 | * type: "zscore", |
| 178 | * prefixes: ["10.0.2.0/24"], |
| 179 | * zscoreSensitivity: "medium", |
| 180 | * zscoreTarget: "bits", |
| 181 | * }); |
| 182 | * ``` |
| 183 | * |
| 184 | * @see https://developers.cloudflare.com/magic-network-monitoring/rules/ |
| 185 | * |
| 186 | * @resource |
| 187 | * @product Magic Network Monitoring |
| 188 | * @category Network |
| 189 | */ |
| 190 | export const Rule = Resource<Rule>(TypeId); |
| 191 | |
| 192 | /** |
| 193 | * Returns true if the given value is a Rule resource. |
| 194 | */ |
| 195 | export const isRule = (value: unknown): value is Rule => |
| 196 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 197 | |
| 198 | export const RuleProvider = () => |
| 199 | Provider.succeed(Rule, { |
| 200 | stables: ["ruleId", "accountId"], |
| 201 | |
| 202 | diff: Effect.fn(function* ({ olds, news, output }) { |
| 203 | if (!isResolved(news)) return undefined; |
| 204 | // The rule type is immutable — alerting semantics differ entirely. |
| 205 | const oldType = |
| 206 | output?.type ?? |
| 207 | (olds !== undefined && isResolved(olds) ? olds.type : undefined); |
| 208 | if (oldType !== undefined && oldType !== news.type) { |
| 209 | return { action: "replace" } as const; |
| 210 | } |
| 211 | // accountId is Input<string>; compare only once both are concrete. |
| 212 | const oldAccount = output?.accountId; |
| 213 | if ( |
| 214 | oldAccount !== undefined && |
| 215 | typeof news.accountId === "string" && |
| 216 | oldAccount !== news.accountId |
| 217 | ) { |
| 218 | return { action: "replace" } as const; |
| 219 | } |
| 220 | return undefined; |
| 221 | }), |
| 222 | |
| 223 | read: Effect.fn(function* ({ id, output, olds }) { |
| 224 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 225 | const acct = |
| 226 | output?.accountId ?? |
| 227 | (typeof olds?.accountId === "string" ? olds.accountId : accountId); |
| 228 | |
| 229 | // Owned path: refresh by our persisted rule id. |
| 230 | if (output?.ruleId) { |
| 231 | const observed = yield* getRule(acct, output.ruleId); |
| 232 | if (observed) return toAttributes(observed, acct); |
| 233 | } |
| 234 | |
| 235 | // Cold read: rule names are unique per account, so an exact name |
| 236 | // match identifies the rule. We cannot prove we created it — brand |
| 237 | // it `Unowned` so takeover is gated behind the adopt policy. |
| 238 | const name = yield* createRuleName(id, olds?.name); |
| 239 | const match = yield* findByName(acct, name); |
| 240 | if (match) { |
| 241 | const attributes = toAttributes(match, acct); |
| 242 | return output === undefined ? Unowned(attributes) : attributes; |
| 243 | } |
| 244 | return undefined; |
| 245 | }), |
| 246 | |
| 247 | reconcile: Effect.fn(function* ({ id, news, output }) { |
| 248 | const { accountId: defaultAccountId } = |
| 249 | yield* yield* CloudflareEnvironment; |
| 250 | // Inputs have been resolved to concrete strings by Plan. |
| 251 | const accountId = |
| 252 | (news.accountId as string | undefined) ?? defaultAccountId; |
| 253 | const name = yield* createRuleName(id, news.name); |
| 254 | |
| 255 | // 1. Observe — the rule id cached on `output` is a hint, not a |
| 256 | // guarantee: a missing rule falls through to the name scan and |
| 257 | // then to create. |
| 258 | let observed = output?.ruleId |
| 259 | ? yield* getRule(accountId, output.ruleId) |
| 260 | : undefined; |
| 261 | if (!observed) { |
| 262 | observed = yield* findByName(accountId, name); |
| 263 | } |
| 264 | |
| 265 | // 2. Ensure — create when missing. A concurrent create surfaces as |
| 266 | // `DuplicateMnmRuleName` (Cloudflare code 1008): converge by |
| 267 | // re-scanning for the rule that won the race. |
| 268 | if (!observed) { |
| 269 | observed = yield* mnm |
| 270 | .createRule({ |
| 271 | accountId, |
| 272 | name, |
| 273 | type: news.type, |
| 274 | prefixes: news.prefixes, |
| 275 | automaticAdvertisement: news.automaticAdvertisement ?? false, |
| 276 | bandwidthThreshold: news.bandwidthThreshold, |
| 277 | packetThreshold: news.packetThreshold, |
| 278 | duration: news.duration, |
| 279 | zscoreSensitivity: news.zscoreSensitivity, |
| 280 | zscoreTarget: news.zscoreTarget, |
| 281 | prefixMatch: news.prefixMatch, |
| 282 | }) |
| 283 | .pipe( |
| 284 | // The account MNM config is a freshly-created dependency (the test |
| 285 | // and real deployments create it immediately before the rule). |
| 286 | // Cloudflare returns `MnmConfigMissing` (code 1004) until the new |
| 287 | // config has propagated, so ride out that consistency window. |
| 288 | Effect.retry({ |
| 289 | while: (e) => e._tag === "MnmConfigMissing", |
| 290 | schedule: Schedule.max([ |
| 291 | Schedule.exponential("500 millis"), |
| 292 | Schedule.recurs(8), |
| 293 | ]), |
| 294 | }), |
| 295 | Effect.catchTag("DuplicateMnmRuleName", (error) => |
| 296 | findByName(accountId, name).pipe( |
| 297 | Effect.flatMap((existing) => |
| 298 | existing ? Effect.succeed(existing) : Effect.fail(error), |
| 299 | ), |
| 300 | ), |
| 301 | ), |
| 302 | ); |
| 303 | return toAttributes(observed, accountId); |
| 304 | } |
| 305 | |
| 306 | // 3. Sync — diff observed cloud state against desired; the PATCH |
| 307 | // takes a full body, so send everything but skip the call |
| 308 | // entirely on a no-op. |
| 309 | const dirty = |
| 310 | observed.name !== name || |
| 311 | !samePrefixes(observed.prefixes, news.prefixes) || |
| 312 | (observed.automaticAdvertisement ?? false) !== |
| 313 | (news.automaticAdvertisement ?? false) || |
| 314 | (news.bandwidthThreshold !== undefined && |
| 315 | observed.bandwidthThreshold !== news.bandwidthThreshold) || |
| 316 | (news.packetThreshold !== undefined && |
| 317 | observed.packetThreshold !== news.packetThreshold) || |
| 318 | (news.duration !== undefined && |
| 319 | normalizeDuration(observed.duration) !== news.duration) || |
| 320 | (news.zscoreSensitivity !== undefined && |
| 321 | observed.zscoreSensitivity !== news.zscoreSensitivity) || |
| 322 | (news.zscoreTarget !== undefined && |
| 323 | observed.zscoreTarget !== news.zscoreTarget) || |
| 324 | (news.prefixMatch !== undefined && |
| 325 | observed.prefixMatch !== news.prefixMatch); |
| 326 | |
| 327 | if (!dirty) return toAttributes(observed, accountId); |
| 328 | |
| 329 | const ruleId = observed.id; |
| 330 | const patched = yield* mnm.patchRule({ |
| 331 | accountId, |
| 332 | ruleId, |
| 333 | name, |
| 334 | type: news.type, |
| 335 | prefixes: news.prefixes, |
| 336 | automaticAdvertisement: news.automaticAdvertisement ?? false, |
| 337 | bandwidthThreshold: news.bandwidthThreshold, |
| 338 | packetThreshold: news.packetThreshold, |
| 339 | duration: news.duration, |
| 340 | zscoreSensitivity: news.zscoreSensitivity, |
| 341 | zscoreTarget: news.zscoreTarget, |
| 342 | prefixMatch: news.prefixMatch, |
| 343 | }); |
| 344 | // Cloudflare omits `id` from the PATCH response body — carry the |
| 345 | // observed id through. |
| 346 | return toAttributes({ ...patched, id: patched.id ?? ruleId }, accountId); |
| 347 | }), |
| 348 | |
| 349 | delete: Effect.fn(function* ({ output }) { |
| 350 | yield* mnm |
| 351 | .deleteRule({ accountId: output.accountId, ruleId: output.ruleId }) |
| 352 | .pipe( |
| 353 | // Already gone (`MnmRuleNotFound`, Cloudflare code 1009) — also |
| 354 | // covers Cloudflare cascading rule deletion when the account's |
| 355 | // MNM config is deleted first. |
| 356 | Effect.catchTag("MnmRuleNotFound", () => Effect.void), |
| 357 | ); |
| 358 | }), |
| 359 | |
| 360 | // MNM rules are account-scoped: enumerate every rule in the ambient |
| 361 | // account. `listRules` paginates in "single" mode, so collect every |
| 362 | // page and flatten its `result` array, hydrating each item into the |
| 363 | // exact `read` Attributes shape. Accounts not onboarded to Magic |
| 364 | // Network Monitoring reject the route with `Forbidden` (HTTP 403) — |
| 365 | // treat that as "nothing to list" rather than an error. |
| 366 | list: Effect.fn(function* () { |
| 367 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 368 | return yield* mnm.listRules.pages({ accountId }).pipe( |
| 369 | Stream.runCollect, |
| 370 | Effect.map((chunk) => |
| 371 | Array.from(chunk).flatMap((page) => |
| 372 | (page.result ?? []) |
| 373 | .filter((rule): rule is NonNullable<typeof rule> => rule != null) |
| 374 | .map((rule) => toAttributes(rule, accountId)), |
| 375 | ), |
| 376 | ), |
| 377 | Effect.catchTag("Forbidden", () => Effect.succeed([])), |
| 378 | ); |
| 379 | }), |
| 380 | }); |
| 381 | |
| 382 | type ObservedRule = mnm.GetRuleResponse; |
| 383 | |
| 384 | /** |
| 385 | * Read a rule by id, mapping "gone" (`MnmRuleNotFound`, Cloudflare error |
| 386 | * code 1009) to `undefined`. |
| 387 | */ |
| 388 | const getRule = (accountId: string, ruleId: string) => |
| 389 | mnm.getRule({ accountId, ruleId }).pipe( |
| 390 | Effect.map((rule): ObservedRule | undefined => rule), |
| 391 | Effect.catchTag("MnmRuleNotFound", () => Effect.succeed(undefined)), |
| 392 | ); |
| 393 | |
| 394 | /** |
| 395 | * Find a rule by exact name. Names are unique per account, so at most one |
| 396 | * rule can match. An account with no rules answers `result: null`. |
| 397 | */ |
| 398 | const findByName = (accountId: string, name: string) => |
| 399 | mnm |
| 400 | .listRules({ accountId }) |
| 401 | .pipe( |
| 402 | Effect.map((response) => |
| 403 | (response.result ?? []).find( |
| 404 | (rule): rule is NonNullable<typeof rule> => rule?.name === name, |
| 405 | ), |
| 406 | ), |
| 407 | ); |
| 408 | |
| 409 | const createRuleName = (id: string, name: string | undefined) => |
| 410 | Effect.gen(function* () { |
| 411 | return name ?? (yield* createPhysicalName({ id, lowercase: true })); |
| 412 | }); |
| 413 | |
| 414 | const samePrefixes = ( |
| 415 | observed: readonly string[], |
| 416 | desired: readonly string[], |
| 417 | ) => |
| 418 | observed.length === desired.length && |
| 419 | [...observed].sort().join(",") === [...desired].sort().join(","); |
| 420 | |
| 421 | /** |
| 422 | * Cloudflare normalizes durations to Go notation on read (`1m` → `1m0s`, |
| 423 | * `60m` → `1h0m0s`) — map the observed value back to the prop vocabulary |
| 424 | * for diffing. |
| 425 | */ |
| 426 | const normalizeDuration = ( |
| 427 | duration: string | null | undefined, |
| 428 | ): RuleDuration | undefined => { |
| 429 | switch (duration) { |
| 430 | case "1m0s": |
| 431 | case "1m": |
| 432 | return "1m"; |
| 433 | case "5m0s": |
| 434 | case "5m": |
| 435 | return "5m"; |
| 436 | case "10m0s": |
| 437 | case "10m": |
| 438 | return "10m"; |
| 439 | case "15m0s": |
| 440 | case "15m": |
| 441 | return "15m"; |
| 442 | case "20m0s": |
| 443 | case "20m": |
| 444 | return "20m"; |
| 445 | case "30m0s": |
| 446 | case "30m": |
| 447 | return "30m"; |
| 448 | case "45m0s": |
| 449 | case "45m": |
| 450 | return "45m"; |
| 451 | case "1h0m0s": |
| 452 | case "60m": |
| 453 | return "60m"; |
| 454 | default: |
| 455 | return undefined; |
| 456 | } |
| 457 | }; |
| 458 | |
| 459 | const toAttributes = ( |
| 460 | rule: ObservedRule | (Omit<mnm.PatchRuleResponse, "id"> & { id: string }), |
| 461 | accountId: string, |
| 462 | ): RuleAttributes => ({ |
| 463 | ruleId: rule.id, |
| 464 | accountId, |
| 465 | name: rule.name, |
| 466 | // Distilled widens generated string enums to open unions (`string & {}`). |
| 467 | type: rule.type as RuleType, |
| 468 | prefixes: [...rule.prefixes], |
| 469 | automaticAdvertisement: rule.automaticAdvertisement ?? false, |
| 470 | bandwidthThreshold: rule.bandwidthThreshold ?? undefined, |
| 471 | packetThreshold: rule.packetThreshold ?? undefined, |
| 472 | duration: rule.duration ?? undefined, |
| 473 | zscoreSensitivity: rule.zscoreSensitivity ?? undefined, |
| 474 | zscoreTarget: rule.zscoreTarget ?? undefined, |
| 475 | prefixMatch: rule.prefixMatch ?? undefined, |
| 476 | }); |