1import type * as EC2 from "@distilled.cloud/aws/ec2";
2import * as ec2 from "@distilled.cloud/aws/ec2";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import type { Providers } from "../Providers.ts";
12import type { NetworkAclId } from "./NetworkAcl.ts";
13
14export interface NetworkAclEntryProps {
15 /**
16 * The ID of the network ACL.
17 */
18 networkAclId: NetworkAclId;
19 /**
20 * The rule number for the entry (1-32766).
21 * Rules are evaluated in order from lowest to highest.
22 */
23 ruleNumber: number;
24 /**
25 * The protocol number.
26 * A value of "-1" means all protocols.
27 * Common values: 6 (TCP), 17 (UDP), 1 (ICMP)
28 */
29 protocol: string;
30 /**
31 * Whether to allow or deny the traffic that matches the rule.
32 */
33 ruleAction: EC2.RuleAction;
34 /**
35 * Whether this is an egress (outbound) rule.
36 * @default false
37 */
38 egress?: boolean;
39 /**
40 * The IPv4 CIDR block.
41 * Either cidrBlock or ipv6CidrBlock must be specified.
42 */
43 cidrBlock?: string;
44 /**
45 * The IPv6 CIDR block.
46 * Either cidrBlock or ipv6CidrBlock must be specified.
47 */
48 ipv6CidrBlock?: string;
49 /**
50 * ICMP type and code. Required if protocol is 1 (ICMP) or 58 (ICMPv6).
51 */
52 icmpTypeCode?: {
53 /** The ICMP code. Use -1 to specify all codes. */
54 code?: number;
55 /** The ICMP type. Use -1 to specify all types. */
56 type?: number;
57 };
58 /**
59 * The port range for TCP/UDP protocols.
60 */
61 portRange?: {
62 /** The first port in the range. */
63 from?: number;
64 /** The last port in the range. */
65 to?: number;
66 };
67}
68
69export interface NetworkAclEntry extends Resource<
70 "AWS.EC2.NetworkAclEntry",
71 NetworkAclEntryProps,
72 {
73 /** The ID of the network ACL. */
74 networkAclId: NetworkAclId;
75 /** The rule number. */
76 ruleNumber: number;
77 /** Whether this is an egress rule. */
78 egress: boolean;
79 /** The protocol. */
80 protocol: string;
81 /** The rule action (allow or deny). */
82 ruleAction: EC2.RuleAction;
83 /** The IPv4 CIDR block. */
84 cidrBlock?: string;
85 /** The IPv6 CIDR block. */
86 ipv6CidrBlock?: string;
87 /** The ICMP type and code. */
88 icmpTypeCode?: {
89 code?: number;
90 type?: number;
91 };
92 /** The port range. */
93 portRange?: {
94 from?: number;
95 to?: number;
96 };
97 },
98 never,
99 Providers
100> {}
101/**
102 * A single rule in a `NetworkAcl` — a numbered, stateless allow/deny entry that
103 * matches traffic by protocol, CIDR (IPv4 or IPv6), and, for TCP/UDP, a port
104 * range.
105 *
106 * Each entry is identified by its `(networkAclId, ruleNumber, egress)` triple,
107 * and changing any of those three replaces the entry. Rules are evaluated from
108 * the lowest `ruleNumber` upward and the first match wins, so leave gaps between
109 * numbers to make room for future rules. Because NACLs are stateless, always add
110 * a matching ephemeral-port rule for return traffic.
111 *
112 * ### Inbound Rules
113 * Inbound rules (`egress: false`) match traffic entering the subnet. A common
114 * pattern is to allow trusted source ranges plus the ephemeral ports needed for
115 * return traffic.
116 * **Example:** Allow Inbound Traffic from the VPC CIDR
117 * ```typescript
118 * const allowVpc = yield* AWS.EC2.NetworkAclEntry("AllowVpc", {
119 * networkAclId: acl.networkAclId,
120 * ruleNumber: 100,
121 * protocol: "-1",
122 * ruleAction: "allow",
123 * egress: false,
124 * cidrBlock: "10.0.0.0/16",
125 * });
126 * ```
127 * `protocol: "-1"` matches all protocols and `cidrBlock` scopes the rule to the
128 * VPC's IPv4 range; the low `ruleNumber` (100) makes it take precedence over
129 * higher-numbered rules.
130 *
131 * **Example:** Allow Inbound Ephemeral Ports (NAT Return Traffic)
132 * ```typescript
133 * const allowEphemeral = yield* AWS.EC2.NetworkAclEntry("AllowEphemeral", {
134 * networkAclId: acl.networkAclId,
135 * ruleNumber: 200,
136 * protocol: "6",
137 * ruleAction: "allow",
138 * egress: false,
139 * cidrBlock: "0.0.0.0/0",
140 * portRange: { from: 1024, to: 65535 },
141 * });
142 * ```
143 * Because the ACL is stateless, responses to outbound requests arrive on
144 * ephemeral ports and need their own inbound rule; `protocol: "6"` is TCP and
145 * `portRange` restricts the match to the ephemeral port range.
146 *
147 * **Example:** Deny a Specific IPv6 Range
148 * ```typescript
149 * const denyRange = yield* AWS.EC2.NetworkAclEntry("DenyBadActor", {
150 * networkAclId: acl.networkAclId,
151 * ruleNumber: 50,
152 * protocol: "-1",
153 * ruleAction: "deny",
154 * egress: false,
155 * ipv6CidrBlock: "2001:db8:1234::/48",
156 * });
157 * ```
158 * `ruleAction: "deny"` with a very low `ruleNumber` blocks an IPv6 range before
159 * any allow rule can match it; use `ipv6CidrBlock` instead of `cidrBlock` to
160 * target IPv6 traffic.
161 *
162 * ### Outbound Rules
163 * Outbound rules (`egress: true`) match traffic leaving the subnet and are
164 * numbered in their own sequence, independent of the inbound rules.
165 * **Example:** Allow All Outbound Traffic
166 * ```typescript
167 * const allowEgress = yield* AWS.EC2.NetworkAclEntry("AllowEgress", {
168 * networkAclId: acl.networkAclId,
169 * ruleNumber: 100,
170 * protocol: "-1",
171 * ruleAction: "allow",
172 * egress: true,
173 * cidrBlock: "0.0.0.0/0",
174 * });
175 * ```
176 * Setting `egress: true` makes this an outbound rule; allowing all protocols to
177 * `0.0.0.0/0` is typical when you want the subnet to initiate connections freely.
178 *
179 * ### ICMP Rules
180 * **Example:** Allow Inbound ICMP Echo (Ping)
181 * ```typescript
182 * const allowPing = yield* AWS.EC2.NetworkAclEntry("AllowPing", {
183 * networkAclId: acl.networkAclId,
184 * ruleNumber: 300,
185 * protocol: "1",
186 * ruleAction: "allow",
187 * egress: false,
188 * cidrBlock: "10.0.0.0/16",
189 * icmpTypeCode: { type: 8, code: -1 },
190 * });
191 * ```
192 * ICMP (`protocol: "1"`) has no ports, so `icmpTypeCode` selects the message
193 * type instead — type 8 is echo request and `code: -1` matches all codes.
194 *
195 * @resource
196 */
197export const NetworkAclEntry = Resource<NetworkAclEntry>(
198 "AWS.EC2.NetworkAclEntry",
199);
200
201export const NetworkAclEntryProvider = () =>
202 Provider.effect(
203 NetworkAclEntry,
204 Effect.gen(function* () {
205 const findEntry = (
206 networkAclId: string,
207 ruleNumber: number,
208 egress: boolean,
209 ) =>
210 ec2.describeNetworkAcls({ NetworkAclIds: [networkAclId] }).pipe(
211 Effect.catchTag("InvalidNetworkAclID.NotFound", () =>
212 Effect.succeed({ NetworkAcls: [] }),
213 ),
214 Effect.map((r) =>
215 r.NetworkAcls?.[0]?.Entries?.find(
216 (e) => e.RuleNumber === ruleNumber && e.Egress === egress,
217 ),
218 ),
219 );
220
221 const toAttrs = (
222 props: NetworkAclEntryProps,
223 entry: NonNullable<
224 Awaited<
225 ReturnType<
226 typeof findEntry extends (
227 ...args: any
228 ) => Effect.Effect<infer R, any, any>
229 ? () => Promise<R>
230 : never
231 >
232 >
233 >,
234 ) => ({
235 networkAclId: props.networkAclId as NetworkAclId,
236 ruleNumber: entry.RuleNumber!,
237 egress: entry.Egress!,
238 protocol: entry.Protocol!,
239 ruleAction: entry.RuleAction!,
240 cidrBlock: entry.CidrBlock,
241 ipv6CidrBlock: entry.Ipv6CidrBlock,
242 icmpTypeCode: entry.IcmpTypeCode
243 ? {
244 code: entry.IcmpTypeCode.Code,
245 type: entry.IcmpTypeCode.Type,
246 }
247 : undefined,
248 portRange: entry.PortRange
249 ? {
250 from: entry.PortRange.From,
251 to: entry.PortRange.To,
252 }
253 : undefined,
254 });
255
256 return {
257 stables: [],
258
259 // Entries are embedded in describeNetworkAcls (each NetworkAcl owns an
260 // Entries[]). Flatten every ACL's entries into the full Attributes
261 // shape. Skip the AWS-managed default-deny rule (32767) since it is not
262 // a user-manageable entry.
263 list: () =>
264 ec2.describeNetworkAcls.pages({}).pipe(
265 Stream.runCollect,
266 Effect.map((chunk) =>
267 Array.from(chunk).flatMap((page) =>
268 (page.NetworkAcls ?? [])
269 // Entries on AWS's default ACL are service-managed account
270 // baseline, not independently owned resources. Enumerating
271 // their rule-100 allow entries made every clean nuke mutate
272 // the rematerialized default VPC and report two false
273 // stragglers.
274 .filter((acl) => acl.IsDefault !== true)
275 .flatMap((acl) =>
276 (acl.Entries ?? [])
277 .filter((e) => e.RuleNumber !== 32767)
278 .map((entry) => ({
279 networkAclId: acl.NetworkAclId as NetworkAclId,
280 ruleNumber: entry.RuleNumber!,
281 egress: entry.Egress!,
282 protocol: entry.Protocol!,
283 ruleAction: entry.RuleAction!,
284 cidrBlock: entry.CidrBlock,
285 ipv6CidrBlock: entry.Ipv6CidrBlock,
286 icmpTypeCode: entry.IcmpTypeCode
287 ? {
288 code: entry.IcmpTypeCode.Code,
289 type: entry.IcmpTypeCode.Type,
290 }
291 : undefined,
292 portRange: entry.PortRange
293 ? {
294 from: entry.PortRange.From,
295 to: entry.PortRange.To,
296 }
297 : undefined,
298 })),
299 ),
300 ),
301 ),
302 ),
303
304 read: Effect.fn(function* ({ olds, output }) {
305 if (!output) return undefined;
306 const entry = yield* findEntry(
307 olds.networkAclId as string,
308 output.ruleNumber,
309 output.egress,
310 );
311 if (!entry) {
312 return yield* Effect.fail(
313 new Error(
314 `Network ACL Entry not found: ${output.networkAclId} rule ${output.ruleNumber} egress=${output.egress}`,
315 ),
316 );
317 }
318 return toAttrs(olds, entry);
319 }),
320
321 diff: Effect.fn(function* ({ news, olds }) {
322 if (!isResolved(news)) return;
323 // If network ACL, rule number, or egress changes, need to replace
324 if (
325 news.networkAclId !== olds.networkAclId ||
326 news.ruleNumber !== olds.ruleNumber ||
327 news.egress !== olds.egress
328 ) {
329 return { action: "replace" };
330 }
331 // Other properties can be updated by replacing the entry
332 }),
333
334 reconcile: Effect.fn(function* ({ news, session }) {
335 const entryParams = {
336 NetworkAclId: news.networkAclId as string,
337 RuleNumber: news.ruleNumber,
338 Protocol: news.protocol,
339 RuleAction: news.ruleAction,
340 Egress: news.egress ?? false,
341 CidrBlock: news.cidrBlock,
342 Ipv6CidrBlock: news.ipv6CidrBlock,
343 IcmpTypeCode: news.icmpTypeCode
344 ? {
345 Code: news.icmpTypeCode.code,
346 Type: news.icmpTypeCode.type,
347 }
348 : undefined,
349 PortRange: news.portRange
350 ? {
351 From: news.portRange.from,
352 To: news.portRange.to,
353 }
354 : undefined,
355 DryRun: false,
356 };
357
358 // Observe — entries are identified by (networkAclId, ruleNumber,
359 // egress); look up the live entry to decide between create and
360 // replace.
361 const observed = yield* findEntry(
362 news.networkAclId as string,
363 news.ruleNumber,
364 news.egress ?? false,
365 );
366
367 // Ensure / Sync — if the entry doesn't exist, create it; otherwise
368 // ReplaceNetworkAclEntry overwrites its mutable properties in place.
369 if (observed === undefined) {
370 yield* session.note(
371 `Creating Network ACL Entry (rule ${news.ruleNumber})...`,
372 );
373 yield* ec2.createNetworkAclEntry(entryParams);
374 yield* session.note(
375 `Network ACL Entry created: rule ${news.ruleNumber}`,
376 );
377 } else {
378 yield* session.note(
379 `Updating Network ACL Entry (rule ${news.ruleNumber})...`,
380 );
381 yield* ec2.replaceNetworkAclEntry(entryParams);
382 yield* session.note(
383 `Network ACL Entry updated: rule ${news.ruleNumber}`,
384 );
385 }
386
387 // Re-read final state. A freshly created/replaced entry can lag
388 // `describeNetworkAcls` by a moment (EC2 eventual consistency), so
389 // poll until it appears, bounded.
390 const entry = yield* findEntry(
391 news.networkAclId as string,
392 news.ruleNumber,
393 news.egress ?? false,
394 ).pipe(
395 Effect.flatMap((e) =>
396 e
397 ? Effect.succeed(e)
398 : Effect.fail({ _tag: "EntryNotYetVisible" } as const),
399 ),
400 Effect.retry({
401 while: (e) => e._tag === "EntryNotYetVisible",
402 schedule: Schedule.max([
403 Schedule.exponential("500 millis"),
404 Schedule.recurs(8),
405 ]),
406 }),
407 Effect.catchTag("EntryNotYetVisible", () =>
408 Effect.fail(
409 new Error("Network ACL Entry not found after reconcile"),
410 ),
411 ),
412 );
413 return toAttrs(news, entry);
414 }),
415
416 delete: Effect.fn(function* ({ output, session }) {
417 const networkAclId = output.networkAclId;
418 yield* session.note(
419 `Deleting Network ACL Entry (rule ${output.ruleNumber})...`,
420 );
421
422 yield* ec2
423 .deleteNetworkAclEntry({
424 NetworkAclId: networkAclId,
425 RuleNumber: output.ruleNumber,
426 Egress: output.egress,
427 DryRun: false,
428 })
429 .pipe(
430 Effect.catchTag(
431 "InvalidNetworkAclEntry.NotFound",
432 () => Effect.void,
433 ),
434 Effect.catchTag(
435 "InvalidNetworkAclID.NotFound",
436 () => Effect.void,
437 ),
438 );
439
440 // Delete success is only an acknowledgement. Observe the exact
441 // (ACL, rule number, direction) tuple until it is absent so nuke
442 // cannot report success while the entry is still enumerable.
443 yield* findEntry(networkAclId, output.ruleNumber, output.egress).pipe(
444 Effect.flatMap((entry) =>
445 entry === undefined
446 ? Effect.void
447 : Effect.fail(
448 new NetworkAclEntryStillVisible({
449 networkAclId,
450 ruleNumber: output.ruleNumber,
451 egress: output.egress,
452 }),
453 ),
454 ),
455 Effect.retry({
456 while: (error) => error instanceof NetworkAclEntryStillVisible,
457 schedule: Schedule.max([
458 Schedule.fixed(1000),
459 Schedule.recurs(10),
460 ]),
461 }),
462 );
463
464 yield* session.note(
465 `Network ACL Entry deleted: rule ${output.ruleNumber}`,
466 );
467 }),
468 };
469 }),
470 );
471
472class NetworkAclEntryStillVisible extends Data.TaggedError(
473 "NetworkAclEntryStillVisible",
474)<{
475 networkAclId: string;
476 ruleNumber: number;
477 egress: boolean;
478}> {}