1import type * as WAFV2 from "@distilled.cloud/aws/wafv2";
2import * as wafv2 from "@distilled.cloud/aws/wafv2";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 hasAlchemyTags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 fetchWafTags,
17 retryAssociatedItem,
18 retryOptimisticLock,
19 syncWafTags,
20 type WafScope,
21 withWafScope,
22} from "./internal.ts";
23
24export interface IPSetProps {
25 /**
26 * Name of the IP set. Must match `^[\w\-]+$` and be 1-128 characters.
27 * Changing the name replaces the IP set.
28 * @default a physical name derived from the app, stage and logical ID
29 */
30 ipSetName?: string;
31 /**
32 * Scope of the IP set — `REGIONAL` (ambient region) or `CLOUDFRONT`
33 * (pinned to `us-east-1`). Must match the scope of the web ACLs and rule
34 * groups that reference it. Changing the scope replaces the IP set.
35 * @default "REGIONAL"
36 */
37 scope?: WafScope;
38 /**
39 * IP address version of the entries. Changing the version replaces the
40 * IP set.
41 * @default "IPV4"
42 */
43 ipAddressVersion?: WAFV2.IPAddressVersion;
44 /**
45 * IP addresses and ranges in CIDR notation (e.g. `192.0.2.44/32`,
46 * `2620:0:2d0:200::/64`). Mutable — updated in place.
47 */
48 addresses: string[];
49 /**
50 * Description of the IP set.
51 */
52 description?: string;
53 /**
54 * User-defined tags to apply to the IP set.
55 */
56 tags?: Record<string, string>;
57}
58
59export interface IPSet extends Resource<
60 "AWS.WAFv2.IPSet",
61 IPSetProps,
62 {
63 /**
64 * Name of the IP set.
65 */
66 ipSetName: string;
67 /**
68 * WAF-assigned unique ID of the IP set.
69 */
70 ipSetId: string;
71 /**
72 * ARN of the IP set — reference it from a rule's
73 * `IPSetReferenceStatement`.
74 */
75 ipSetArn: string;
76 /**
77 * Scope the IP set was created in.
78 */
79 scope: WafScope;
80 /**
81 * IP address version of the entries.
82 */
83 ipAddressVersion: WAFV2.IPAddressVersion;
84 /**
85 * Current addresses in the set.
86 */
87 addresses: string[];
88 },
89 never,
90 Providers
91> {}
92
93/**
94 * An AWS WAFv2 IP set — a named collection of IP addresses and CIDR ranges
95 * referenced from web ACL and rule group rules via
96 * `IPSetReferenceStatement`.
97 *
98 * ### Creating IP Sets
99 * **Example:** Block List of IPv4 Addresses
100 * ```typescript
101 * const blockList = yield* AWS.WAFv2.IPSet("BlockList", {
102 * addresses: ["192.0.2.44/32", "203.0.113.0/24"],
103 * });
104 * ```
105 *
106 * **Example:** Reference from a Web ACL Rule
107 * ```typescript
108 * const acl = yield* AWS.WAFv2.WebACL("Firewall", {
109 * rules: [
110 * {
111 * Name: "block-bad-ips",
112 * Priority: 0,
113 * Statement: {
114 * IPSetReferenceStatement: { ARN: blockList.ipSetArn },
115 * },
116 * Action: { Block: {} },
117 * VisibilityConfig: {
118 * SampledRequestsEnabled: true,
119 * CloudWatchMetricsEnabled: true,
120 * MetricName: "block-bad-ips",
121 * },
122 * },
123 * ],
124 * });
125 * ```
126 *
127 * @resource
128 */
129export const IPSet = Resource<IPSet>("AWS.WAFv2.IPSet");
130
131const defaultScope: WafScope = "REGIONAL";
132const defaultIpVersion: WAFV2.IPAddressVersion = "IPV4";
133
134const sortedAddresses = (addresses: readonly string[]) =>
135 [...addresses].sort((a, b) => a.localeCompare(b));
136
137const toAttrs = (ipSet: WAFV2.IPSet, scope: WafScope) => ({
138 ipSetName: ipSet.Name,
139 ipSetId: ipSet.Id,
140 ipSetArn: ipSet.ARN,
141 scope,
142 ipAddressVersion: ipSet.IPAddressVersion,
143 addresses: [...ipSet.Addresses],
144});
145
146export const IPSetProvider = () =>
147 Provider.effect(
148 IPSet,
149 Effect.gen(function* () {
150 const createName = Effect.fn(function* (id: string, props: IPSetProps) {
151 return (
152 props.ipSetName ?? (yield* createPhysicalName({ id, maxLength: 128 }))
153 );
154 });
155
156 const findIPSet = Effect.fn(function* (
157 scope: WafScope,
158 name: string,
159 cachedId: string | undefined,
160 ) {
161 if (cachedId !== undefined) {
162 const byId = yield* withWafScope(
163 scope,
164 wafv2
165 .getIPSet({ Name: name, Scope: scope, Id: cachedId })
166 .pipe(
167 Effect.catchTag("WAFNonexistentItemException", () =>
168 Effect.succeed(undefined),
169 ),
170 ),
171 );
172 if (byId?.IPSet) {
173 return byId;
174 }
175 }
176 let marker: string | undefined;
177 for (let page = 0; page < 20; page++) {
178 const listed = yield* withWafScope(
179 scope,
180 wafv2.listIPSets({ Scope: scope, NextMarker: marker, Limit: 100 }),
181 );
182 const summary = listed.IPSets?.find((s) => s.Name === name);
183 if (summary?.Id !== undefined) {
184 return yield* withWafScope(
185 scope,
186 wafv2
187 .getIPSet({ Name: name, Scope: scope, Id: summary.Id })
188 .pipe(
189 Effect.catchTag("WAFNonexistentItemException", () =>
190 Effect.succeed(undefined),
191 ),
192 ),
193 );
194 }
195 if (!listed.NextMarker || (listed.IPSets?.length ?? 0) === 0) {
196 break;
197 }
198 marker = listed.NextMarker;
199 }
200 return undefined;
201 });
202
203 const listScope = Effect.fn(function* (scope: WafScope) {
204 const rows: ReturnType<typeof toAttrs>[] = [];
205 let marker: string | undefined;
206 for (let page = 0; page < 50; page++) {
207 const listed = yield* withWafScope(
208 scope,
209 wafv2.listIPSets({ Scope: scope, NextMarker: marker, Limit: 100 }),
210 );
211 const summaries = listed.IPSets ?? [];
212 const details = yield* Effect.forEach(
213 summaries,
214 (summary) =>
215 summary.Name !== undefined && summary.Id !== undefined
216 ? withWafScope(
217 scope,
218 wafv2
219 .getIPSet({
220 Name: summary.Name,
221 Scope: scope,
222 Id: summary.Id,
223 })
224 .pipe(
225 Effect.catchTag("WAFNonexistentItemException", () =>
226 Effect.succeed(undefined),
227 ),
228 ),
229 )
230 : Effect.succeed(undefined),
231 { concurrency: 5 },
232 );
233 for (const detail of details) {
234 if (detail?.IPSet) {
235 rows.push(toAttrs(detail.IPSet, scope));
236 }
237 }
238 if (!listed.NextMarker || summaries.length === 0) {
239 break;
240 }
241 marker = listed.NextMarker;
242 }
243 return rows;
244 });
245
246 return {
247 stables: [
248 "ipSetName",
249 "ipSetId",
250 "ipSetArn",
251 "scope",
252 "ipAddressVersion",
253 ],
254
255 list: () =>
256 Effect.gen(function* () {
257 const regional = yield* listScope("REGIONAL");
258 const cloudfront = yield* listScope("CLOUDFRONT");
259 return [...regional, ...cloudfront];
260 }),
261
262 diff: Effect.fn(function* ({ id, news, olds }) {
263 if (!isResolved(news)) return undefined;
264 const oldName = yield* createName(id, olds ?? { addresses: [] });
265 const newName = yield* createName(id, news);
266 if (
267 oldName !== newName ||
268 (olds?.scope ?? defaultScope) !== (news.scope ?? defaultScope) ||
269 (olds?.ipAddressVersion ?? defaultIpVersion) !==
270 (news.ipAddressVersion ?? defaultIpVersion)
271 ) {
272 return { action: "replace" } as const;
273 }
274 }),
275
276 read: Effect.fn(function* ({ id, olds, output }) {
277 const scope = output?.scope ?? olds?.scope ?? defaultScope;
278 const name =
279 output?.ipSetName ??
280 (yield* createName(id, olds ?? { addresses: [] }));
281 const found = yield* findIPSet(scope, name, output?.ipSetId);
282 if (!found?.IPSet) {
283 return undefined;
284 }
285 const attrs = toAttrs(found.IPSet, scope);
286 const tags = yield* fetchWafTags(scope, found.IPSet.ARN);
287 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
288 }),
289
290 reconcile: Effect.fn(function* ({ id, news, output, session }) {
291 const scope = news.scope ?? defaultScope;
292 const name = output?.ipSetName ?? (yield* createName(id, news));
293 const internalTags = yield* createInternalTags(id);
294 const desiredTags = { ...news.tags, ...internalTags };
295
296 // 1. Observe.
297 let observed = yield* findIPSet(scope, name, output?.ipSetId);
298
299 // 2. Ensure — tolerate a duplicate-create race and re-read.
300 if (!observed?.IPSet) {
301 yield* withWafScope(
302 scope,
303 wafv2
304 .createIPSet({
305 Name: name,
306 Scope: scope,
307 IPAddressVersion: news.ipAddressVersion ?? defaultIpVersion,
308 Addresses: news.addresses,
309 Description: news.description,
310 Tags: createTagsList(desiredTags),
311 })
312 .pipe(
313 Effect.catchTag("WAFDuplicateItemException", () =>
314 Effect.succeed(undefined),
315 ),
316 ),
317 );
318 observed = yield* findIPSet(scope, name, undefined);
319 }
320
321 if (!observed?.IPSet) {
322 return yield* Effect.fail(
323 new Error(`Failed to observe IPSet '${name}' after create`),
324 );
325 }
326
327 const ipSet = observed.IPSet;
328 yield* session.note(ipSet.ARN);
329
330 // 3. Sync addresses + description against OBSERVED state. WAF
331 // returns addresses in arbitrary order — compare as sets.
332 const drifted = !deepEqual(
333 {
334 Addresses: sortedAddresses(ipSet.Addresses),
335 Description: ipSet.Description,
336 },
337 {
338 Addresses: sortedAddresses(news.addresses),
339 Description: news.description,
340 },
341 { stripNullish: true },
342 );
343 if (drifted) {
344 yield* retryOptimisticLock(
345 Effect.gen(function* () {
346 const fresh = yield* findIPSet(scope, name, ipSet.Id);
347 if (!fresh?.IPSet || fresh.LockToken === undefined) {
348 return;
349 }
350 yield* withWafScope(
351 scope,
352 wafv2.updateIPSet({
353 Name: name,
354 Scope: scope,
355 Id: fresh.IPSet.Id,
356 Addresses: news.addresses,
357 Description: news.description,
358 LockToken: fresh.LockToken,
359 }),
360 );
361 }),
362 );
363 }
364
365 // 3b. Sync tags against OBSERVED cloud tags.
366 yield* syncWafTags(scope, ipSet.ARN, desiredTags);
367
368 // 4. Return fresh attributes.
369 return {
370 ...toAttrs(ipSet, scope),
371 addresses: [...news.addresses],
372 };
373 }),
374
375 delete: Effect.fn(function* ({ output }) {
376 const scope = output.scope;
377 // An IP set still referenced by a web ACL rule (deletion
378 // propagation) surfaces WAFAssociatedItemException — retry it.
379 yield* retryAssociatedItem(
380 retryOptimisticLock(
381 Effect.gen(function* () {
382 const found = yield* withWafScope(
383 scope,
384 wafv2
385 .getIPSet({
386 Name: output.ipSetName,
387 Scope: scope,
388 Id: output.ipSetId,
389 })
390 .pipe(
391 Effect.catchTag("WAFNonexistentItemException", () =>
392 Effect.succeed(undefined),
393 ),
394 ),
395 );
396 if (!found?.IPSet || found.LockToken === undefined) {
397 return;
398 }
399 yield* withWafScope(
400 scope,
401 wafv2
402 .deleteIPSet({
403 Name: output.ipSetName,
404 Scope: scope,
405 Id: output.ipSetId,
406 LockToken: found.LockToken,
407 })
408 .pipe(
409 Effect.catchTag(
410 "WAFNonexistentItemException",
411 () => Effect.void,
412 ),
413 ),
414 );
415 }),
416 ),
417 );
418 }),
419 };
420 }),
421 );