| 1 | import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Redacted from "effect/Redacted"; |
| 5 | |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import { isResolved } from "../../Diff.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | |
| 13 | const TypeId = "Cloudflare.MagicTransit.GreTunnel" as const; |
| 14 | type TypeId = typeof TypeId; |
| 15 | |
| 16 | /** |
| 17 | * Health-check configuration for a Magic Transit tunnel. |
| 18 | */ |
| 19 | export 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 | */ |
| 51 | export 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 | |
| 66 | export 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 | |
| 123 | export 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 | |
| 150 | export 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 | */ |
| 209 | export const GreTunnel = Resource<GreTunnel>(TypeId); |
| 210 | |
| 211 | /** |
| 212 | * Returns true if the given value is a GreTunnel resource. |
| 213 | */ |
| 214 | export const isGreTunnel = (value: unknown): value is GreTunnel => |
| 215 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 216 | |
| 217 | export 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 | |
| 371 | interface 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 | */ |
| 399 | const 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 | */ |
| 413 | const 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 | |
| 422 | const 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 | |
| 430 | const 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 | |
| 442 | const 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 | |
| 460 | const 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 | |
| 475 | const 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 | }); |