1import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
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.GreTunnel" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Health-check configuration for a Magic Transit tunnel.
18 */
19export interface MagicTunnelHealthCheck {
20 /**
21 * Whether tunnel health checks are enabled.
22 * @default true
23 */
24 enabled?: boolean;
25 /**
26 * Direction of the health check: `unidirectional` (default for Magic
27 * Transit) probes only Cloudflare-to-customer, `bidirectional` probes
28 * both directions.
29 */
30 direction?: "unidirectional" | "bidirectional";
31 /**
32 * Rate at which health checks are sent.
33 * @default "mid"
34 */
35 rate?: "low" | "mid" | "high";
36 /**
37 * Health-check type: `reply` (default) expects ICMP replies, `request`
38 * sends ICMP requests only.
39 */
40 type?: "reply" | "request";
41 /**
42 * The customer IP the health check targets. Defaults to
43 * `customer_gre_endpoint` when omitted.
44 */
45 target?: string;
46}
47
48/**
49 * BGP configuration for a Magic Transit tunnel.
50 */
51export interface MagicTunnelBgp {
52 /**
53 * ASN used on the customer end of the BGP session.
54 */
55 customerAsn: number;
56 /**
57 * Prefixes advertised in addition to the account's Magic prefixes.
58 */
59 extraPrefixes?: string[];
60 /**
61 * MD5 key to use for session authentication. Write-only.
62 */
63 md5Key?: Redacted.Redacted<string>;
64}
65
66export interface GreTunnelProps {
67 /**
68 * The name of the tunnel. Cannot contain spaces or special characters,
69 * must be 15 characters or less, and cannot share a name with another GRE
70 * tunnel. Immutable in practice — changing it triggers a replacement.
71 */
72 name: string;
73 /**
74 * The IP address assigned to the Cloudflare side of the GRE tunnel (a
75 * Cloudflare anycast IP allocated to the account).
76 */
77 cloudflareGreEndpoint: string;
78 /**
79 * The IP address assigned to the customer side of the GRE tunnel.
80 */
81 customerGreEndpoint: string;
82 /**
83 * A 31-bit prefix (/31 in CIDR notation) from RFC1918 private space; one
84 * host for each side of the tunnel.
85 */
86 interfaceAddress: string;
87 /**
88 * A /127 IPv6 prefix from within the account's `virtual_subnet6` space.
89 */
90 interfaceAddress6?: string;
91 /**
92 * An optional description of the GRE tunnel.
93 */
94 description?: string;
95 /**
96 * Time To Live (TTL) in number of hops of the GRE tunnel.
97 * @default 64
98 */
99 ttl?: number;
100 /**
101 * Maximum Transmission Unit (MTU) in bytes for the GRE tunnel. Minimum
102 * value is 576.
103 * @default 1476
104 */
105 mtu?: number;
106 /**
107 * Tunnel health-check configuration.
108 */
109 healthCheck?: MagicTunnelHealthCheck;
110 /**
111 * BGP configuration. The update API cannot change BGP settings —
112 * changing this triggers a replacement.
113 */
114 bgp?: MagicTunnelBgp;
115 /**
116 * True if automatic stateful return routing should be enabled. Requires
117 * the `coupler_integration` account flag.
118 * @default false
119 */
120 automaticReturnRouting?: boolean;
121}
122
123export interface GreTunnelAttributes {
124 /** Cloudflare-assigned identifier of the GRE tunnel. */
125 tunnelId: string;
126 /** The Cloudflare account the tunnel belongs to. */
127 accountId: string;
128 /** The name of the tunnel. */
129 name: string;
130 /** The IP address on the Cloudflare side of the tunnel. */
131 cloudflareGreEndpoint: string;
132 /** The IP address on the customer side of the tunnel. */
133 customerGreEndpoint: string;
134 /** The /31 interface address of the tunnel. */
135 interfaceAddress: string;
136 /** The /127 IPv6 interface address, if configured. */
137 interfaceAddress6: string | undefined;
138 /** The tunnel description, if set. */
139 description: string | undefined;
140 /** Time To Live (TTL) in number of hops. */
141 ttl: number | undefined;
142 /** Maximum Transmission Unit (MTU) in bytes. */
143 mtu: number | undefined;
144 /** ISO8601 creation timestamp. */
145 createdOn: string | undefined;
146 /** ISO8601 last-modified timestamp. */
147 modifiedOn: string | undefined;
148}
149
150export type GreTunnel = Resource<
151 TypeId,
152 GreTunnelProps,
153 GreTunnelAttributes,
154 never,
155 Providers
156>;
157
158/**
159 * A Magic Transit / Magic WAN GRE tunnel between Cloudflare and a customer
160 * router.
161 *
162 * Requires a Magic Transit or Magic WAN subscription on the account —
163 * accounts that are not onboarded receive a typed
164 * `MagicTransitNotOnboarded` error (Cloudflare code 1012).
165 *
166 * The tunnel `name` is its routing identity (unique, ≤15 chars) — changing
167 * it triggers a replacement, as does changing `bgp` (the update API cannot
168 * modify BGP settings). Everything else is updated in place via PUT.
169 * ### Creating a GRE tunnel
170 * **Example:** Basic tunnel
171 * ```typescript
172 * const tunnel = yield* Cloudflare.MagicTransit.GreTunnel("office", {
173 * name: "office-gre-1",
174 * cloudflareGreEndpoint: "203.0.113.1",
175 * customerGreEndpoint: "198.51.100.1",
176 * interfaceAddress: "10.213.0.8/31",
177 * });
178 * ```
179 *
180 * **Example:** Tunnel with health checks and MTU
181 * ```typescript
182 * const tunnel = yield* Cloudflare.MagicTransit.GreTunnel("office", {
183 * name: "office-gre-1",
184 * cloudflareGreEndpoint: "203.0.113.1",
185 * customerGreEndpoint: "198.51.100.1",
186 * interfaceAddress: "10.213.0.8/31",
187 * mtu: 1476,
188 * ttl: 64,
189 * healthCheck: { enabled: true, rate: "mid", type: "reply" },
190 * });
191 * ```
192 *
193 * ### Routing traffic over the tunnel
194 * **Example:** Static route via the tunnel interface
195 * ```typescript
196 * yield* Cloudflare.MagicTransit.MagicStaticRoute("office-route", {
197 * prefix: "10.100.0.0/24",
198 * nexthop: "10.213.0.9",
199 * priority: 100,
200 * });
201 * ```
202 *
203 * @see https://developers.cloudflare.com/magic-transit/
204 *
205 * @resource
206 * @product Magic Transit
207 * @category Network
208 */
209export const GreTunnel = Resource<GreTunnel>(TypeId);
210
211/**
212 * Returns true if the given value is a GreTunnel resource.
213 */
214export const isGreTunnel = (value: unknown): value is GreTunnel =>
215 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
216
217export const GreTunnelProvider = () =>
218 Provider.succeed(GreTunnel, {
219 stables: ["tunnelId", "accountId", "createdOn"],
220
221 diff: Effect.fn(function* ({ olds, news }) {
222 if (!isResolved(news)) return undefined;
223 if (olds === undefined) return undefined;
224 // The tunnel name is its routing identity; renames are rejected.
225 if (olds.name !== news.name) return { action: "replace" } as const;
226 // The update API has no `bgp` field — BGP changes require recreate.
227 if (!sameBgp(olds.bgp, news.bgp)) return { action: "replace" } as const;
228 return undefined;
229 }),
230
231 read: Effect.fn(function* ({ output, olds }) {
232 const { accountId } = yield* yield* CloudflareEnvironment;
233 const acct = output?.accountId ?? accountId;
234
235 if (output?.tunnelId) {
236 const observed = yield* getTunnel(acct, output.tunnelId);
237 if (observed) return toAttributes(observed, acct);
238 }
239 // Cold read — tunnel names are unique per account, so an exact name
240 // match identifies the tunnel. Tunnels carry no ownership markers;
241 // report as Unowned so takeover is gated behind the adopt policy.
242 const name = output?.name ?? olds?.name;
243 if (name) {
244 const observed = yield* findByName(acct, name);
245 if (observed) return Unowned(toAttributes(observed, acct));
246 }
247 return undefined;
248 }),
249
250 reconcile: Effect.fn(function* ({ news, output }) {
251 const { accountId } = yield* yield* CloudflareEnvironment;
252
253 // Observe — the id on `output` is a hint; fall through to the
254 // unique-name lookup when it is gone.
255 let observed = output?.tunnelId
256 ? yield* getTunnel(accountId, output.tunnelId)
257 : undefined;
258 if (!observed) {
259 observed = yield* findByName(accountId, news.name);
260 }
261
262 // Ensure — create when missing.
263 if (!observed) {
264 const created = yield* magicTransit.createGreTunnel({
265 accountId,
266 xMagicNewHcTarget: true,
267 name: news.name,
268 cloudflareGreEndpoint: news.cloudflareGreEndpoint,
269 customerGreEndpoint: news.customerGreEndpoint,
270 interfaceAddress: news.interfaceAddress,
271 interfaceAddress6: news.interfaceAddress6,
272 description: news.description,
273 ttl: news.ttl,
274 mtu: news.mtu,
275 automaticReturnRouting: news.automaticReturnRouting,
276 bgp: news.bgp
277 ? {
278 customerAsn: news.bgp.customerAsn,
279 extraPrefixes: news.bgp.extraPrefixes,
280 md5Key: news.bgp.md5Key
281 ? Redacted.value(news.bgp.md5Key)
282 : undefined,
283 }
284 : undefined,
285 healthCheck: news.healthCheck
286 ? {
287 enabled: news.healthCheck.enabled,
288 target: news.healthCheck.target
289 ? { saved: news.healthCheck.target }
290 : undefined,
291 }
292 : undefined,
293 });
294 observed = created;
295 }
296
297 // Sync — diff observed cloud state against desired; the update API
298 // is a full PUT, so send everything, but skip the call on a no-op.
299 // This also converges health-check fields the create API does not
300 // accept (direction/rate/type).
301 if (dirty(observed, news)) {
302 const updated = yield* magicTransit.updateGreTunnel({
303 accountId,
304 greTunnelId: observed.id,
305 xMagicNewHcTarget: true,
306 name: news.name,
307 cloudflareGreEndpoint: news.cloudflareGreEndpoint,
308 customerGreEndpoint: news.customerGreEndpoint,
309 interfaceAddress: news.interfaceAddress,
310 interfaceAddress6: news.interfaceAddress6,
311 description: news.description,
312 ttl: news.ttl,
313 mtu: news.mtu,
314 automaticReturnRouting: news.automaticReturnRouting,
315 healthCheck: news.healthCheck
316 ? {
317 enabled: news.healthCheck.enabled,
318 direction: news.healthCheck.direction,
319 rate: news.healthCheck.rate,
320 type: news.healthCheck.type,
321 target: news.healthCheck.target
322 ? { saved: news.healthCheck.target }
323 : undefined,
324 }
325 : undefined,
326 });
327 if (updated.modifiedGreTunnel) {
328 observed = updated.modifiedGreTunnel;
329 } else {
330 // Defensive: re-read when the PUT response omits the tunnel.
331 observed = (yield* getTunnel(accountId, observed.id)) ?? observed;
332 }
333 }
334
335 return toAttributes(observed, accountId);
336 }),
337
338 delete: Effect.fn(function* ({ output }) {
339 yield* magicTransit
340 .deleteGreTunnel({
341 accountId: output.accountId,
342 greTunnelId: output.tunnelId,
343 xMagicNewHcTarget: true,
344 })
345 .pipe(Effect.catchTag("GreTunnelNotFound", () => Effect.void));
346 }),
347
348 // Account-scoped collection. `listGreTunnels` returns the full set in one
349 // (non-paginated) call, so there is nothing to paginate. Accounts without
350 // a Magic Transit subscription reject with the typed
351 // `MagicTransitNotOnboarded` tag (code 1012) — and, depending on token
352 // scope, a typed `Forbidden` (403). Both mean the account cannot enumerate
353 // Magic Transit, so treat them as non-listable → [].
354 list: Effect.fn(function* () {
355 const { accountId } = yield* yield* CloudflareEnvironment;
356 return yield* magicTransit
357 .listGreTunnels({ accountId, xMagicNewHcTarget: true })
358 .pipe(
359 Effect.map((r): GreTunnelAttributes[] =>
360 (r.greTunnels ?? []).map((tunnel) =>
361 toAttributes(tunnel, accountId),
362 ),
363 ),
364 Effect.catchTag(["MagicTransitNotOnboarded", "Forbidden"], () =>
365 Effect.succeed<GreTunnelAttributes[]>([]),
366 ),
367 );
368 }),
369 });
370
371interface ObservedGreTunnel {
372 id: string;
373 name: string;
374 cloudflareGreEndpoint: string;
375 customerGreEndpoint: string;
376 interfaceAddress: string;
377 interfaceAddress6?: string | null;
378 description?: string | null;
379 ttl?: number | null;
380 mtu?: number | null;
381 healthCheck?: {
382 direction?: string | null;
383 enabled?: boolean | null;
384 rate?: string | null;
385 target?:
386 | { effective?: string | null; saved?: string | null }
387 | string
388 | null;
389 type?: string | null;
390 } | null;
391 createdOn?: string | null;
392 modifiedOn?: string | null;
393}
394
395/**
396 * Read a tunnel by id, mapping "gone" (`GreTunnelNotFound`, Cloudflare
397 * error code 1029) to `undefined`.
398 */
399const getTunnel = (accountId: string, greTunnelId: string) =>
400 magicTransit
401 .getGreTunnel({ accountId, greTunnelId, xMagicNewHcTarget: true })
402 .pipe(
403 Effect.map(
404 (r): ObservedGreTunnel | undefined => r.greTunnel ?? undefined,
405 ),
406 Effect.catchTag("GreTunnelNotFound", () => Effect.succeed(undefined)),
407 );
408
409/**
410 * Find a tunnel by exact name. Names are unique per account, so at most
411 * one tunnel can match.
412 */
413const findByName = (accountId: string, name: string) =>
414 magicTransit
415 .listGreTunnels({ accountId, xMagicNewHcTarget: true })
416 .pipe(
417 Effect.map((r): ObservedGreTunnel | undefined =>
418 (r.greTunnels ?? []).find((t) => t.name === name),
419 ),
420 );
421
422const observedHealthTarget = (
423 healthCheck: ObservedGreTunnel["healthCheck"],
424): string | undefined => {
425 const target = healthCheck?.target;
426 if (typeof target === "string") return target;
427 return target?.saved ?? undefined;
428};
429
430const dirty = (observed: ObservedGreTunnel, news: GreTunnelProps): boolean =>
431 observed.cloudflareGreEndpoint !== news.cloudflareGreEndpoint ||
432 observed.customerGreEndpoint !== news.customerGreEndpoint ||
433 observed.interfaceAddress !== news.interfaceAddress ||
434 (news.interfaceAddress6 !== undefined &&
435 (observed.interfaceAddress6 ?? undefined) !== news.interfaceAddress6) ||
436 (news.description !== undefined &&
437 (observed.description ?? undefined) !== news.description) ||
438 (news.ttl !== undefined && (observed.ttl ?? undefined) !== news.ttl) ||
439 (news.mtu !== undefined && (observed.mtu ?? undefined) !== news.mtu) ||
440 healthCheckDirty(observed, news.healthCheck);
441
442const healthCheckDirty = (
443 observed: ObservedGreTunnel,
444 desired: MagicTunnelHealthCheck | undefined,
445): boolean => {
446 if (desired === undefined) return false;
447 const hc = observed.healthCheck ?? {};
448 return (
449 (desired.enabled !== undefined &&
450 (hc.enabled ?? undefined) !== desired.enabled) ||
451 (desired.direction !== undefined &&
452 (hc.direction ?? undefined) !== desired.direction) ||
453 (desired.rate !== undefined && (hc.rate ?? undefined) !== desired.rate) ||
454 (desired.type !== undefined && (hc.type ?? undefined) !== desired.type) ||
455 (desired.target !== undefined &&
456 observedHealthTarget(hc) !== desired.target)
457 );
458};
459
460const sameBgp = (
461 a: MagicTunnelBgp | undefined,
462 b: MagicTunnelBgp | undefined,
463): boolean => {
464 if (a === undefined && b === undefined) return true;
465 if (a === undefined || b === undefined) return false;
466 const aKey = a.md5Key ? Redacted.value(a.md5Key) : undefined;
467 const bKey = b.md5Key ? Redacted.value(b.md5Key) : undefined;
468 return (
469 a.customerAsn === b.customerAsn &&
470 aKey === bKey &&
471 (a.extraPrefixes ?? []).join(",") === (b.extraPrefixes ?? []).join(",")
472 );
473};
474
475const toAttributes = (
476 tunnel: ObservedGreTunnel,
477 accountId: string,
478): GreTunnelAttributes => ({
479 tunnelId: tunnel.id,
480 accountId,
481 name: tunnel.name,
482 cloudflareGreEndpoint: tunnel.cloudflareGreEndpoint,
483 customerGreEndpoint: tunnel.customerGreEndpoint,
484 interfaceAddress: tunnel.interfaceAddress,
485 interfaceAddress6: tunnel.interfaceAddress6 ?? undefined,
486 description: tunnel.description ?? undefined,
487 ttl: tunnel.ttl ?? undefined,
488 mtu: tunnel.mtu ?? undefined,
489 createdOn: tunnel.createdOn ?? undefined,
490 modifiedOn: tunnel.modifiedOn ?? undefined,
491});