1import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit";
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.MagicTransit.SiteAcl" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Protocols a Magic WAN site ACL can match.
18 */
19export type MagicSiteAclProtocol = "tcp" | "udp" | "icmp";
20
21/**
22 * One side of a Magic WAN site ACL — selects a LAN and optionally narrows
23 * the match to specific ports, port ranges, and subnets.
24 */
25export interface MagicSiteAclLan {
26 /** The LAN identifier this side of the ACL applies to. */
27 lanId: string;
28 /** Display name of the LAN (informational). */
29 lanName?: string;
30 /** Specific ports to match. */
31 ports?: number[];
32 /** Port ranges to match, e.g. `"8080-8090"`. */
33 portRanges?: string[];
34 /** Subnets to match (IPv4 CIDR or address). */
35 subnets?: string[];
36}
37
38export interface MagicSiteAclProps {
39 /**
40 * The site this ACL belongs to. Changing it triggers a replacement.
41 */
42 siteId: string;
43 /**
44 * The name of the ACL.
45 */
46 name: string;
47 /**
48 * The first LAN of the ACL pair.
49 */
50 lan1: MagicSiteAclLan;
51 /**
52 * The second LAN of the ACL pair.
53 */
54 lan2: MagicSiteAclLan;
55 /**
56 * Description for the ACL.
57 */
58 description?: string;
59 /**
60 * If `true`, traffic is forwarded locally on the Magic Connector; if
61 * `false`, traffic is forwarded to Cloudflare.
62 * @default false
63 */
64 forwardLocally?: boolean;
65 /**
66 * Protocols the ACL matches. Omit to match all protocols.
67 */
68 protocols?: MagicSiteAclProtocol[];
69 /**
70 * If `true`, the policy allows traffic in one direction only
71 * (lan1 → lan2); if `false`, traffic is bidirectional.
72 * @default false
73 */
74 unidirectional?: boolean;
75}
76
77export interface MagicSiteAclAttributes {
78 /** Cloudflare-assigned identifier of the ACL. */
79 aclId: string;
80 /** The site the ACL belongs to. */
81 siteId: string;
82 /** The Cloudflare account the ACL belongs to. */
83 accountId: string;
84 /** The name of the ACL. */
85 name: string;
86 /** The ACL description, if set. */
87 description: string | undefined;
88 /** Whether traffic is forwarded locally on the connector. */
89 forwardLocally: boolean | undefined;
90 /** Protocols the ACL matches, if narrowed. */
91 protocols: MagicSiteAclProtocol[] | undefined;
92 /** Whether the ACL is unidirectional. */
93 unidirectional: boolean | undefined;
94}
95
96export type MagicSiteAcl = Resource<
97 TypeId,
98 MagicSiteAclProps,
99 MagicSiteAclAttributes,
100 never,
101 Providers
102>;
103
104/**
105 * An ACL between two LANs of a Magic WAN site — allows traffic between
106 * LAN segments behind a Magic WAN Connector (all inter-LAN traffic is
107 * denied by default).
108 *
109 * Requires a Magic WAN subscription — accounts without it receive a typed
110 * `MagicWanUnauthorized` error (Cloudflare code 1025).
111 *
112 * `siteId` is create-only — changing it triggers a replacement. Everything
113 * else is updated in place.
114 * ### Creating an ACL
115 * **Example:** Allow TCP between two LANs
116 * ```typescript
117 * yield* Cloudflare.MagicTransit.MagicSiteAcl("lan-to-lan", {
118 * siteId: site.siteId,
119 * name: "office-to-lab",
120 * lan1: { lanId: officeLan.lanId, ports: [443] },
121 * lan2: { lanId: labLan.lanId },
122 * protocols: ["tcp"],
123 * });
124 * ```
125 *
126 * **Example:** Unidirectional ACL forwarded locally
127 * ```typescript
128 * yield* Cloudflare.MagicTransit.MagicSiteAcl("one-way", {
129 * siteId: site.siteId,
130 * name: "sensors-to-collector",
131 * lan1: { lanId: sensorsLan.lanId },
132 * lan2: { lanId: collectorLan.lanId, ports: [9000] },
133 * unidirectional: true,
134 * forwardLocally: true,
135 * });
136 * ```
137 *
138 * @see https://developers.cloudflare.com/magic-wan/configuration/connector/network-options/site-acls/
139 *
140 * @resource
141 * @product Magic Transit
142 * @category Network
143 */
144export const MagicSiteAcl = Resource<MagicSiteAcl>(TypeId);
145
146/**
147 * Returns true if the given value is a MagicSiteAcl resource.
148 */
149export const isMagicSiteAcl = (value: unknown): value is MagicSiteAcl =>
150 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
151
152export const MagicSiteAclProvider = () =>
153 Provider.succeed(MagicSiteAcl, {
154 stables: ["aclId", "siteId", "accountId"],
155
156 diff: Effect.fn(function* ({ olds, news }) {
157 if (!isResolved(news)) return undefined;
158 if (olds === undefined) return undefined;
159 // ACLs cannot move between sites.
160 if (
161 typeof olds.siteId === "string" &&
162 typeof news.siteId === "string" &&
163 olds.siteId !== news.siteId
164 ) {
165 return { action: "replace" } as const;
166 }
167 return undefined;
168 }),
169
170 read: Effect.fn(function* ({ output, olds }) {
171 const { accountId } = yield* yield* CloudflareEnvironment;
172 const acct = output?.accountId ?? accountId;
173 const siteId =
174 output?.siteId ??
175 (typeof olds?.siteId === "string" ? olds.siteId : undefined);
176 if (!siteId) return undefined;
177
178 if (output?.aclId) {
179 const observed = yield* getAcl(acct, siteId, output.aclId);
180 if (observed) return toAttributes(observed, siteId, acct);
181 }
182 // Cold read — match by name within the site. ACLs carry no
183 // ownership markers; report as Unowned so takeover is gated behind
184 // the adopt policy.
185 const name = output?.name ?? olds?.name;
186 if (name) {
187 const observed = yield* findByName(acct, siteId, name);
188 if (observed) return Unowned(toAttributes(observed, siteId, acct));
189 }
190 return undefined;
191 }),
192
193 reconcile: Effect.fn(function* ({ news, output }) {
194 const { accountId } = yield* yield* CloudflareEnvironment;
195 // Inputs have been resolved to concrete strings by Plan.
196 const siteId = news.siteId as string;
197 const lan1 = toLanRequest(news.lan1);
198 const lan2 = toLanRequest(news.lan2);
199
200 // Observe — the id on `output` is a hint; fall back to a name scan.
201 let observed = output?.aclId
202 ? yield* getAcl(accountId, siteId, output.aclId)
203 : undefined;
204 if (!observed) {
205 observed = yield* findByName(accountId, siteId, news.name);
206 }
207
208 // Ensure — create when missing.
209 if (!observed) {
210 const created = yield* magicTransit.createSiteAcl({
211 accountId,
212 siteId,
213 name: news.name,
214 lan_1: lan1,
215 lan_2: lan2,
216 description: news.description,
217 forwardLocally: news.forwardLocally,
218 protocols: news.protocols,
219 unidirectional: news.unidirectional,
220 });
221 return toAttributes(created, siteId, accountId);
222 }
223
224 // Sync — the update API is a PUT; send the full desired state, but
225 // skip the call entirely on a no-op.
226 const dirty =
227 (observed.name ?? undefined) !== news.name ||
228 (news.description !== undefined &&
229 (observed.description ?? undefined) !== news.description) ||
230 (news.forwardLocally !== undefined &&
231 (observed.forwardLocally ?? false) !== news.forwardLocally) ||
232 (news.unidirectional !== undefined &&
233 (observed.unidirectional ?? false) !== news.unidirectional) ||
234 (news.protocols !== undefined &&
235 !sameList(observed.protocols, news.protocols)) ||
236 lanDirty(observed.lan_1, lan1) ||
237 lanDirty(observed.lan_2, lan2);
238 if (dirty) {
239 const updated = yield* magicTransit.updateSiteAcl({
240 accountId,
241 siteId,
242 aclId: observed.id!,
243 name: news.name,
244 lan_1: lan1,
245 lan_2: lan2,
246 description: news.description,
247 forwardLocally: news.forwardLocally,
248 protocols: news.protocols,
249 unidirectional: news.unidirectional,
250 });
251 observed = { ...updated, id: updated.id ?? observed.id };
252 }
253
254 return toAttributes(observed, siteId, accountId);
255 }),
256
257 delete: Effect.fn(function* ({ output }) {
258 yield* magicTransit
259 .deleteSiteAcl({
260 accountId: output.accountId,
261 siteId: output.siteId,
262 aclId: output.aclId,
263 })
264 .pipe(Effect.catchTag("SiteAclNotFound", () => Effect.void));
265 }),
266
267 // Parent fan-out: ACLs are sub-resources keyed by site, and there is no
268 // account-wide ACL enumeration API. Enumerate every Magic site (account
269 // scope), then list ACLs per site with bounded concurrency, paginating
270 // each list exhaustively. Magic WAN-gated accounts (and partial-scope
271 // tokens) reject these routes with the typed `MagicWanUnauthorized`
272 // (code 1025) / `Forbidden` tags — treat those as "nothing to list".
273 list: Effect.fn(function* () {
274 const { accountId } = yield* yield* CloudflareEnvironment;
275
276 const siteIds = yield* magicTransit.listSites.pages({ accountId }).pipe(
277 Stream.runCollect,
278 Effect.map((chunk) =>
279 Array.from(chunk).flatMap((page) =>
280 (page.result ?? []).flatMap((site) => (site.id ? [site.id] : [])),
281 ),
282 ),
283 Effect.catchTag(["MagicWanUnauthorized", "Forbidden"], () =>
284 Effect.succeed([] as string[]),
285 ),
286 );
287
288 const rows = yield* Effect.forEach(
289 siteIds,
290 (siteId) =>
291 magicTransit.listSiteAcls.pages({ accountId, siteId }).pipe(
292 Stream.runCollect,
293 Effect.map((chunk) =>
294 Array.from(chunk).flatMap((page) =>
295 (page.result ?? []).map((acl) =>
296 toAttributes(acl, siteId, accountId),
297 ),
298 ),
299 ),
300 // Site vanished or became inaccessible mid-enumeration — skip it.
301 Effect.catchTag(["MagicWanUnauthorized", "Forbidden"], () =>
302 Effect.succeed([] as MagicSiteAclAttributes[]),
303 ),
304 ),
305 { concurrency: 10 },
306 );
307
308 return rows.flat();
309 }),
310 });
311
312interface ObservedAclLan {
313 lanId: string;
314 lanName?: string | null;
315 ports?: number[] | null;
316 portRanges?: string[] | null;
317 subnets?: string[] | null;
318}
319
320interface ObservedAcl {
321 id?: string | null;
322 name?: string | null;
323 description?: string | null;
324 forwardLocally?: boolean | null;
325 protocols?: string[] | null;
326 unidirectional?: boolean | null;
327 lan_1?: ObservedAclLan | null;
328 lan_2?: ObservedAclLan | null;
329}
330
331interface AclLanRequest {
332 lanId: string;
333 lanName?: string;
334 ports?: number[];
335 portRanges?: string[];
336 subnets?: string[];
337}
338
339/**
340 * Read an ACL by id, mapping "gone" (`SiteAclNotFound`, HTTP 404) to
341 * `undefined`.
342 */
343const getAcl = (accountId: string, siteId: string, aclId: string) =>
344 magicTransit.getSiteAcl({ accountId, siteId, aclId }).pipe(
345 Effect.map((acl): ObservedAcl | undefined => acl),
346 Effect.catchTag("SiteAclNotFound", () => Effect.succeed(undefined)),
347 );
348
349/**
350 * Find an ACL by exact name within a site. Names are not enforced unique;
351 * pick the first match deterministically by id.
352 */
353const findByName = (accountId: string, siteId: string, name: string) =>
354 magicTransit.listSiteAcls({ accountId, siteId }).pipe(
355 Effect.map((r): ObservedAcl | undefined =>
356 r.result
357 .filter((acl) => acl.name === name)
358 .sort((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))
359 .at(0),
360 ),
361 );
362
363const toLanRequest = (lan: MagicSiteAclLan): AclLanRequest => ({
364 // Inputs have been resolved to concrete strings by Plan.
365 lanId: lan.lanId as string,
366 lanName: lan.lanName,
367 ports: lan.ports,
368 portRanges: lan.portRanges,
369 subnets: lan.subnets,
370});
371
372const sameList = (
373 a: readonly (string | number)[] | null | undefined,
374 b: readonly (string | number)[] | undefined,
375): boolean =>
376 [...(a ?? [])].sort().join(",") === [...(b ?? [])].sort().join(",");
377
378const lanDirty = (
379 observed: ObservedAclLan | null | undefined,
380 desired: AclLanRequest,
381): boolean =>
382 (observed?.lanId ?? undefined) !== desired.lanId ||
383 (desired.ports !== undefined && !sameList(observed?.ports, desired.ports)) ||
384 (desired.portRanges !== undefined &&
385 !sameList(observed?.portRanges, desired.portRanges)) ||
386 (desired.subnets !== undefined &&
387 !sameList(observed?.subnets, desired.subnets));
388
389const toAttributes = (
390 acl: ObservedAcl,
391 siteId: string,
392 accountId: string,
393): MagicSiteAclAttributes => ({
394 aclId: acl.id ?? "",
395 siteId,
396 accountId,
397 name: acl.name ?? "",
398 description: acl.description ?? undefined,
399 forwardLocally: acl.forwardLocally ?? undefined,
400 protocols: acl.protocols
401 ? acl.protocols.map((p) => p as MagicSiteAclProtocol)
402 : undefined,
403 unidirectional: acl.unidirectional ?? undefined,
404});