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 { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.MagicTransit.SiteLan" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * NAT configuration for a Magic WAN site LAN.
19 */
20export interface MagicSiteLanNat {
21 /** A valid CIDR notation representing an IP range. */
22 staticPrefix?: string;
23}
24
25/**
26 * A subnet routed behind a Magic WAN site LAN.
27 */
28export interface MagicSiteLanRoutedSubnet {
29 /** A valid IPv4 address for the subnet's next hop. */
30 nextHop: string;
31 /** A valid CIDR notation representing an IP range. */
32 prefix: string;
33 /** Optional NAT configuration for the routed subnet. */
34 nat?: MagicSiteLanNat;
35}
36
37/**
38 * Static addressing configuration for a Magic WAN site LAN. If the site is
39 * not in high availability mode this is optional (DHCP is used when
40 * omitted); in HA mode it is required along with a secondary address.
41 */
42export interface MagicSiteLanStaticAddressing {
43 /** A valid CIDR notation representing the LAN address. */
44 address: string;
45 /** Secondary address, required when the site is in HA mode. */
46 secondaryAddress?: string;
47 /** Virtual address shared by HA connector pairs. */
48 virtualAddress?: string;
49 /** DHCP relay configuration. */
50 dhcpRelay?: {
51 /** List of DHCP server addresses. */
52 serverAddresses?: string[];
53 };
54 /** DHCP server configuration. */
55 dhcpServer?: {
56 /** End of the DHCP address pool. */
57 dhcpPoolEnd?: string;
58 /** Start of the DHCP address pool. */
59 dhcpPoolStart?: string;
60 /** A single DNS server address. */
61 dnsServer?: string;
62 /** DNS server addresses. */
63 dnsServers?: string[];
64 /** Mapping of MAC addresses to IP addresses. */
65 reservations?: Record<string, unknown>;
66 };
67}
68
69export interface MagicSiteLanProps {
70 /**
71 * The site this LAN belongs to. Changing it triggers a replacement.
72 */
73 siteId: string;
74 /**
75 * The physical port number on the connector this LAN is attached to.
76 */
77 physport: number;
78 /**
79 * The name of the LAN. If omitted, a unique name is generated from the
80 * app, stage, and logical ID.
81 * @default ${app}-${stage}-${id}
82 */
83 name?: string;
84 /**
85 * VLAN ID. Use zero for untagged.
86 * @default 0
87 */
88 vlanTag?: number;
89 /**
90 * Mark true to use this LAN for HA probing. Only works for sites with
91 * HA turned on; only one LAN can be the ha_link. Create-only — changing
92 * it triggers a replacement.
93 */
94 haLink?: boolean;
95 /**
96 * Mark true to use this LAN for source-based breakout traffic.
97 */
98 isBreakout?: boolean;
99 /**
100 * Mark true to use this LAN for source-based prioritized traffic.
101 */
102 isPrioritized?: boolean;
103 /**
104 * NAT configuration for the LAN.
105 */
106 nat?: MagicSiteLanNat;
107 /**
108 * Subnets routed behind this LAN.
109 */
110 routedSubnets?: MagicSiteLanRoutedSubnet[];
111 /**
112 * Static addressing configuration; omit to use DHCP.
113 */
114 staticAddressing?: MagicSiteLanStaticAddressing;
115 /**
116 * Bond identifier when the LAN is part of a link bond.
117 */
118 bondId?: number;
119}
120
121export interface MagicSiteLanAttributes {
122 /** Cloudflare-assigned identifier of the LAN. */
123 lanId: string;
124 /** The site the LAN belongs to. */
125 siteId: string;
126 /** The Cloudflare account the LAN belongs to. */
127 accountId: string;
128 /** The name of the LAN. */
129 name: string;
130 /** The physical port number. */
131 physport: number | undefined;
132 /** The VLAN ID (zero for untagged). */
133 vlanTag: number | undefined;
134 /** Whether this LAN is the HA probing link. */
135 haLink: boolean | undefined;
136}
137
138export type MagicSiteLan = Resource<
139 TypeId,
140 MagicSiteLanProps,
141 MagicSiteLanAttributes,
142 never,
143 Providers
144>;
145
146/**
147 * A LAN attached to a Magic WAN site — describes a local network segment
148 * behind a Magic WAN Connector port (VLAN, addressing, routed subnets,
149 * NAT).
150 *
151 * Requires a Magic WAN subscription — accounts without it receive a typed
152 * `MagicWanUnauthorized` error (Cloudflare code 1025).
153 *
154 * `siteId` and `haLink` are create-only — changing either triggers a
155 * replacement. Everything else is updated in place.
156 * ### Creating a LAN
157 * **Example:** Untagged LAN with DHCP
158 * ```typescript
159 * const lan = yield* Cloudflare.MagicTransit.MagicSiteLan("hq-lan", {
160 * siteId: site.siteId,
161 * physport: 2,
162 * vlanTag: 0,
163 * });
164 * ```
165 *
166 * **Example:** LAN with static addressing and a routed subnet
167 * ```typescript
168 * const lan = yield* Cloudflare.MagicTransit.MagicSiteLan("hq-lan", {
169 * siteId: site.siteId,
170 * physport: 2,
171 * vlanTag: 10,
172 * staticAddressing: { address: "192.168.10.1/24" },
173 * routedSubnets: [
174 * { prefix: "10.10.0.0/24", nextHop: "192.168.10.254" },
175 * ],
176 * });
177 * ```
178 *
179 * @see https://developers.cloudflare.com/magic-wan/configuration/connector/
180 *
181 * @resource
182 * @product Magic Transit
183 * @category Network
184 */
185export const MagicSiteLan = Resource<MagicSiteLan>(TypeId);
186
187/**
188 * Returns true if the given value is a MagicSiteLan resource.
189 */
190export const isMagicSiteLan = (value: unknown): value is MagicSiteLan =>
191 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
192
193export const MagicSiteLanProvider = () =>
194 Provider.succeed(MagicSiteLan, {
195 stables: ["lanId", "siteId", "accountId"],
196
197 diff: Effect.fn(function* ({ olds, news }) {
198 if (!isResolved(news)) return undefined;
199 if (olds === undefined) return undefined;
200 // LANs cannot move between sites.
201 if (
202 typeof olds.siteId === "string" &&
203 typeof news.siteId === "string" &&
204 olds.siteId !== news.siteId
205 ) {
206 return { action: "replace" } as const;
207 }
208 // ha_link is create-only — the update API has no such field.
209 if ((olds.haLink ?? false) !== (news.haLink ?? false)) {
210 return { action: "replace" } as const;
211 }
212 return undefined;
213 }),
214
215 read: Effect.fn(function* ({ id, output, olds }) {
216 const { accountId } = yield* yield* CloudflareEnvironment;
217 const acct = output?.accountId ?? accountId;
218 const siteId =
219 output?.siteId ??
220 (typeof olds?.siteId === "string" ? olds.siteId : undefined);
221 if (!siteId) return undefined;
222
223 if (output?.lanId) {
224 const observed = yield* getLan(acct, siteId, output.lanId);
225 if (observed) return toAttributes(observed, siteId, acct);
226 }
227 // Cold read — match the deterministic physical name. LANs carry no
228 // ownership markers; report as Unowned so takeover is gated behind
229 // the adopt policy.
230 const name = yield* createLanName(id, olds?.name);
231 const observed = yield* findByName(acct, siteId, name);
232 if (observed) return Unowned(toAttributes(observed, siteId, acct));
233 return undefined;
234 }),
235
236 reconcile: Effect.fn(function* ({ id, news, output }) {
237 const { accountId } = yield* yield* CloudflareEnvironment;
238 // Inputs have been resolved to concrete strings by Plan.
239 const siteId = news.siteId as string;
240 const name = yield* createLanName(id, news.name);
241
242 // Observe — the id on `output` is a hint; fall back to a name scan.
243 let observed = output?.lanId
244 ? yield* getLan(accountId, siteId, output.lanId)
245 : undefined;
246 if (!observed) {
247 observed = yield* findByName(accountId, siteId, name);
248 }
249
250 // Ensure — create when missing. The create API returns the site's
251 // LAN list; pick the created LAN by name.
252 if (!observed) {
253 const created = yield* magicTransit.createSiteLan({
254 accountId,
255 siteId,
256 name,
257 physport: news.physport,
258 vlanTag: news.vlanTag,
259 haLink: news.haLink,
260 isBreakout: news.isBreakout,
261 isPrioritized: news.isPrioritized,
262 nat: news.nat,
263 routedSubnets: news.routedSubnets,
264 staticAddressing: news.staticAddressing,
265 bondId: news.bondId,
266 });
267 observed =
268 created.result.find((lan) => lan.name === name) ??
269 created.result.at(0);
270 if (!observed) {
271 // Defensive: converge via the list if the create response shape
272 // is unexpected.
273 observed = yield* findByName(accountId, siteId, name);
274 }
275 if (observed) return toAttributes(observed, siteId, accountId);
276 return yield* Effect.fail(
277 new Error(`Magic WAN site LAN ${name} not visible after create`),
278 );
279 }
280
281 // Sync — the update API is a PUT; send the full desired state, but
282 // skip the call entirely on a no-op of the observable fields.
283 const dirty =
284 (observed.name ?? undefined) !== name ||
285 (observed.physport ?? undefined) !== news.physport ||
286 (news.vlanTag !== undefined &&
287 (observed.vlanTag ?? undefined) !== news.vlanTag) ||
288 (news.isBreakout !== undefined &&
289 (observed.isBreakout ?? false) !== news.isBreakout) ||
290 (news.isPrioritized !== undefined &&
291 (observed.isPrioritized ?? false) !== news.isPrioritized) ||
292 (news.nat !== undefined &&
293 (observed.nat?.staticPrefix ?? undefined) !==
294 news.nat.staticPrefix) ||
295 (news.routedSubnets !== undefined &&
296 !sameRoutedSubnets(observed.routedSubnets, news.routedSubnets)) ||
297 (news.staticAddressing !== undefined &&
298 (observed.staticAddressing?.address ?? undefined) !==
299 news.staticAddressing.address) ||
300 (news.bondId !== undefined &&
301 (observed.bondId ?? undefined) !== news.bondId);
302 if (dirty) {
303 const updated = yield* magicTransit.updateSiteLan({
304 accountId,
305 siteId,
306 lanId: observed.id!,
307 name,
308 physport: news.physport,
309 vlanTag: news.vlanTag,
310 isBreakout: news.isBreakout,
311 isPrioritized: news.isPrioritized,
312 nat: news.nat,
313 routedSubnets: news.routedSubnets,
314 staticAddressing: news.staticAddressing,
315 bondId: news.bondId,
316 });
317 observed = { ...updated, id: updated.id ?? observed.id };
318 }
319
320 return toAttributes(observed, siteId, accountId);
321 }),
322
323 delete: Effect.fn(function* ({ output }) {
324 yield* magicTransit
325 .deleteSiteLan({
326 accountId: output.accountId,
327 siteId: output.siteId,
328 lanId: output.lanId,
329 })
330 .pipe(Effect.catchTag("SiteLanNotFound", () => Effect.void));
331 }),
332
333 // LANs are sub-resources keyed by their parent Magic WAN site, which is
334 // not enumerable by LAN. Fan out: list every account-scoped site, then
335 // exhaustively paginate the LANs of each site (bounded concurrency) and
336 // hydrate into the same Attributes shape `read` returns. Accounts (or
337 // individual sites) without Magic WAN entitlement reject with the typed
338 // `MagicWanUnauthorized` (Cloudflare code 1025) — nothing to enumerate,
339 // so skip → [].
340 list: Effect.fn(function* () {
341 const { accountId } = yield* yield* CloudflareEnvironment;
342
343 const siteIds = yield* magicTransit.listSites.pages({ accountId }).pipe(
344 Stream.runCollect,
345 Effect.map((chunk) =>
346 Array.from(chunk).flatMap((page) =>
347 (page.result ?? [])
348 .map((site) => site.id)
349 .filter((id): id is string => typeof id === "string"),
350 ),
351 ),
352 Effect.catchTag("MagicWanUnauthorized", () =>
353 Effect.succeed([] as string[]),
354 ),
355 );
356
357 const rows = yield* Effect.forEach(
358 siteIds,
359 (siteId) =>
360 magicTransit.listSiteLans.pages({ accountId, siteId }).pipe(
361 Stream.runCollect,
362 Effect.map((chunk) =>
363 Array.from(chunk).flatMap((page) =>
364 (page.result ?? []).map((lan) =>
365 toAttributes(lan, siteId, accountId),
366 ),
367 ),
368 ),
369 Effect.catchTag("MagicWanUnauthorized", () =>
370 Effect.succeed([] as MagicSiteLanAttributes[]),
371 ),
372 ),
373 { concurrency: 10 },
374 );
375
376 return rows.flat();
377 }),
378 });
379
380interface ObservedLan {
381 id?: string | null;
382 name?: string | null;
383 physport?: number | null;
384 vlanTag?: number | null;
385 haLink?: boolean | null;
386 isBreakout?: boolean | null;
387 isPrioritized?: boolean | null;
388 nat?: { staticPrefix?: string | null } | null;
389 routedSubnets?:
390 | {
391 nextHop: string;
392 prefix: string;
393 nat?: { staticPrefix?: string | null } | null;
394 }[]
395 | null;
396 staticAddressing?: { address: string } | null;
397 bondId?: number | null;
398}
399
400/**
401 * Read a LAN by id, mapping "gone" (`SiteLanNotFound`, HTTP 404) to
402 * `undefined`.
403 */
404const getLan = (accountId: string, siteId: string, lanId: string) =>
405 magicTransit.getSiteLan({ accountId, siteId, lanId }).pipe(
406 Effect.map((lan): ObservedLan | undefined => lan),
407 Effect.catchTag("SiteLanNotFound", () => Effect.succeed(undefined)),
408 );
409
410/**
411 * Find a LAN by exact name within a site. Names are not enforced unique;
412 * pick the first match deterministically by id.
413 */
414const findByName = (accountId: string, siteId: string, name: string) =>
415 magicTransit.listSiteLans({ accountId, siteId }).pipe(
416 Effect.map((r): ObservedLan | undefined =>
417 r.result
418 .filter((lan) => lan.name === name)
419 .sort((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))
420 .at(0),
421 ),
422 );
423
424const createLanName = (id: string, name: string | undefined) =>
425 Effect.gen(function* () {
426 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
427 });
428
429const sameRoutedSubnets = (
430 observed: ObservedLan["routedSubnets"],
431 desired: MagicSiteLanRoutedSubnet[],
432): boolean => {
433 const key = (s: { prefix: string; nextHop: string }) =>
434 `${s.prefix}>${s.nextHop}`;
435 return (
436 [...(observed ?? [])].map(key).sort().join(",") ===
437 [...desired].map(key).sort().join(",")
438 );
439};
440
441const toAttributes = (
442 lan: ObservedLan,
443 siteId: string,
444 accountId: string,
445): MagicSiteLanAttributes => ({
446 lanId: lan.id ?? "",
447 siteId,
448 accountId,
449 name: lan.name ?? "",
450 physport: lan.physport ?? undefined,
451 vlanTag: lan.vlanTag ?? undefined,
452 haLink: lan.haLink ?? undefined,
453});