| 1 | import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Option from "effect/Option"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | export type RouteProps = { |
| 13 | /** |
| 14 | * UUID of the `cfd_tunnel` this route attaches to. |
| 15 | * |
| 16 | * Stable -- changing the tunnel triggers replacement. |
| 17 | */ |
| 18 | tunnelId: string; |
| 19 | /** |
| 20 | * Private IPv4 or IPv6 range exposed through the tunnel, in CIDR notation |
| 21 | * (e.g. `10.4.0.0/16`). |
| 22 | * |
| 23 | * Stable -- changing the CIDR triggers replacement. |
| 24 | * |
| 25 | * Declared as a plain `string` (not `string`) so it is statically |
| 26 | * knowable inside `diff`. |
| 27 | */ |
| 28 | network: string; |
| 29 | /** |
| 30 | * Optional human-readable note attached to the route. |
| 31 | * |
| 32 | * Mutable -- changes are applied in place via PATCH. |
| 33 | */ |
| 34 | comment?: string; |
| 35 | /** |
| 36 | * Optional Tunnel Virtual Network UUID. Use to disambiguate overlapping |
| 37 | * CIDRs that live in separate virtual networks. |
| 38 | * |
| 39 | * Stable -- changing the virtual network triggers replacement. |
| 40 | */ |
| 41 | virtualNetworkId?: string; |
| 42 | /** |
| 43 | * Whether to adopt an existing route with the same network (and virtual |
| 44 | * network, when set) on the same tunnel if one is already present. |
| 45 | * |
| 46 | * @default false |
| 47 | */ |
| 48 | adopt?: boolean; |
| 49 | }; |
| 50 | |
| 51 | export type Route = Resource< |
| 52 | "Cloudflare.Tunnel.Route", |
| 53 | RouteProps, |
| 54 | { |
| 55 | /** |
| 56 | * UUID of the route, assigned by Cloudflare. |
| 57 | */ |
| 58 | routeId: string; |
| 59 | /** |
| 60 | * The CIDR exposed through the tunnel. |
| 61 | */ |
| 62 | network: string; |
| 63 | /** |
| 64 | * UUID of the tunnel this route attaches to. |
| 65 | */ |
| 66 | tunnelId: string; |
| 67 | /** |
| 68 | * Cloudflare account that owns the route. |
| 69 | */ |
| 70 | accountId: string; |
| 71 | /** |
| 72 | * Human-readable note attached to the route, if any. |
| 73 | */ |
| 74 | comment: string | undefined; |
| 75 | /** |
| 76 | * UUID of the Tunnel Virtual Network the route lives in, if any. |
| 77 | */ |
| 78 | virtualNetworkId: string | undefined; |
| 79 | /** |
| 80 | * RFC 3339 timestamp of when the route was created (as reported by |
| 81 | * Cloudflare), if available. |
| 82 | */ |
| 83 | createdAt: string | undefined; |
| 84 | }, |
| 85 | never, |
| 86 | Providers |
| 87 | >; |
| 88 | |
| 89 | /** |
| 90 | * A Cloudflare Tunnel Route attaches a private CIDR to a `cfd_tunnel` so that |
| 91 | * WARP clients (and other Zero Trust egress paths) can reach private IPs |
| 92 | * through the tunnel. |
| 93 | * ### Creating a Route |
| 94 | * **Example:** Basic route |
| 95 | * ```typescript |
| 96 | * const tunnel = yield* Cloudflare.Tunnel.Tunnel("MyTunnel"); |
| 97 | * const route = yield* Cloudflare.Tunnel.Route("PrivateNet", { |
| 98 | * tunnelId: tunnel.tunnelId, |
| 99 | * network: "10.4.0.0/16", |
| 100 | * }); |
| 101 | * ``` |
| 102 | * |
| 103 | * **Example:** Route with a comment and explicit virtual network |
| 104 | * ```typescript |
| 105 | * const route = yield* Cloudflare.Tunnel.Route("DcRoute", { |
| 106 | * tunnelId: tunnel.tunnelId, |
| 107 | * network: "10.50.0.0/16", |
| 108 | * comment: "Datacenter A private subnet", |
| 109 | * virtualNetworkId: vnet.id, |
| 110 | * adopt: true, |
| 111 | * }); |
| 112 | * ``` |
| 113 | * |
| 114 | * @resource |
| 115 | * @product Tunnels |
| 116 | * @category Cloudflare One (Zero Trust) |
| 117 | */ |
| 118 | export const Route = Resource<Route>("Cloudflare.Tunnel.Route", { |
| 119 | aliases: ["Cloudflare.TunnelRoute"], |
| 120 | }); |
| 121 | |
| 122 | export const RouteProvider = () => |
| 123 | Provider.succeed(Route, { |
| 124 | stables: [ |
| 125 | "routeId", |
| 126 | "accountId", |
| 127 | "tunnelId", |
| 128 | "network", |
| 129 | "virtualNetworkId", |
| 130 | ], |
| 131 | diff: Effect.fn(function* ({ olds = {}, news, output }) { |
| 132 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 133 | |
| 134 | if (!isResolved(news)) return undefined; |
| 135 | const acct = output?.accountId ?? accountId; |
| 136 | if (acct !== accountId) { |
| 137 | return { action: "replace" } as const; |
| 138 | } |
| 139 | if (output?.network !== undefined && output.network !== news.network) { |
| 140 | return { action: "replace" } as const; |
| 141 | } |
| 142 | if (olds.network !== undefined && olds.network !== news.network) { |
| 143 | return { action: "replace" } as const; |
| 144 | } |
| 145 | const oldTunnelId = output?.tunnelId ?? olds.tunnelId; |
| 146 | if (oldTunnelId !== undefined && oldTunnelId !== news.tunnelId) { |
| 147 | return { action: "replace" } as const; |
| 148 | } |
| 149 | // virtualNetworkId is auto-assigned by Cloudflare when omitted |
| 150 | // (every account has a "default" virtual network that absorbs |
| 151 | // routes without an explicit assignment). If the caller didn't |
| 152 | // ask for a specific vnet (`news.virtualNetworkId === undefined`), |
| 153 | // they're delegating to CF — don't force a replace just because |
| 154 | // CF auto-assigned one. Only mark replace when the caller has |
| 155 | // EXPLICITLY chosen a vnet and it differs from what's deployed. |
| 156 | if ( |
| 157 | news.virtualNetworkId !== undefined && |
| 158 | (output?.virtualNetworkId ?? olds.virtualNetworkId) !== |
| 159 | news.virtualNetworkId |
| 160 | ) { |
| 161 | return { action: "replace" } as const; |
| 162 | } |
| 163 | }), |
| 164 | reconcile: Effect.fn(function* ({ news, output }) { |
| 165 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 166 | |
| 167 | const acct = output?.accountId ?? accountId; |
| 168 | const tunnelId = news.tunnelId; |
| 169 | const network = news.network; |
| 170 | const virtualNetworkId = news.virtualNetworkId; |
| 171 | |
| 172 | // Observe — cached id first, then an account-wide list scan by |
| 173 | // network so we recover from out-of-band deletes, partial state |
| 174 | // writes, or a route left attached to another tunnel. |
| 175 | let observed = yield* observe( |
| 176 | acct, |
| 177 | network, |
| 178 | virtualNetworkId, |
| 179 | output?.routeId, |
| 180 | ); |
| 181 | |
| 182 | // Ensure — create when missing. Cloudflare returns a generic |
| 183 | // conflict on duplicate (network, tunnel, vnet) tuples; on any |
| 184 | // such race re-observe and either adopt or rethrow. |
| 185 | if (!observed) { |
| 186 | // Cloudflare returns an untagged generic error on a |
| 187 | // duplicate (network, tunnel, vnet) tuple. distilled |
| 188 | // surfaces it as the untyped Cloudflare error bucket, |
| 189 | // so we can't `catchTag` -- we narrow by re-observing |
| 190 | // and only swallowing the error when adoption is |
| 191 | // requested AND a matching route actually exists. |
| 192 | const createdOrAdopted = yield* zeroTrust |
| 193 | .createNetworkRoute({ |
| 194 | accountId: acct, |
| 195 | network, |
| 196 | tunnelId, |
| 197 | comment: news.comment, |
| 198 | virtualNetworkId, |
| 199 | }) |
| 200 | .pipe( |
| 201 | Effect.catch((err) => |
| 202 | Effect.gen(function* () { |
| 203 | if (!news.adopt) return yield* Effect.fail(err); |
| 204 | const existing = yield* observe( |
| 205 | acct, |
| 206 | network, |
| 207 | virtualNetworkId, |
| 208 | undefined, |
| 209 | ); |
| 210 | if (!existing) return yield* Effect.fail(err); |
| 211 | // Sentinel: undefined means "adoption path; use re-observed value". |
| 212 | return undefined; |
| 213 | }), |
| 214 | ), |
| 215 | ); |
| 216 | observed = createdOrAdopted |
| 217 | ? toObserved(createdOrAdopted) |
| 218 | : yield* observe(acct, network, virtualNetworkId, undefined); |
| 219 | } |
| 220 | |
| 221 | if (!observed) { |
| 222 | return yield* Effect.die( |
| 223 | `Route create returned no id for network ${network} on tunnel ${tunnelId}`, |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | // Sync — `comment` is mutable, and an adopted route may be |
| 228 | // attached to a different tunnel (the account-wide network |
| 229 | // uniqueness means we adopt whatever route owns the CIDR) so we |
| 230 | // repoint `tunnelId` too. Skip the PATCH entirely on no-op so we |
| 231 | // avoid churn on every reconcile. Clearing a comment must send |
| 232 | // `""` explicitly — omitting every body field produces an empty |
| 233 | // PATCH body which Cloudflare rejects with a JSON parse error. |
| 234 | const desiredComment = news.comment ?? ""; |
| 235 | const needsComment = (observed.comment ?? "") !== desiredComment; |
| 236 | const needsTunnel = observed.tunnelId !== tunnelId; |
| 237 | if (needsComment || needsTunnel) { |
| 238 | const patched = yield* zeroTrust.patchNetworkRoute({ |
| 239 | accountId: acct, |
| 240 | routeId: observed.id, |
| 241 | ...(needsComment ? { comment: desiredComment } : {}), |
| 242 | ...(needsTunnel ? { tunnelId } : {}), |
| 243 | }); |
| 244 | observed = { |
| 245 | id: patched.id ?? observed.id, |
| 246 | network: normalize(patched.network) ?? observed.network, |
| 247 | tunnelId: normalize(patched.tunnelId) ?? observed.tunnelId, |
| 248 | virtualNetworkId: |
| 249 | normalize(patched.virtualNetworkId) ?? observed.virtualNetworkId, |
| 250 | comment: normalize(patched.comment), |
| 251 | createdAt: normalize(patched.createdAt) ?? observed.createdAt, |
| 252 | }; |
| 253 | } |
| 254 | |
| 255 | return { |
| 256 | routeId: observed.id, |
| 257 | network: observed.network ?? network, |
| 258 | tunnelId: observed.tunnelId ?? tunnelId, |
| 259 | accountId: acct, |
| 260 | comment: observed.comment, |
| 261 | virtualNetworkId: observed.virtualNetworkId ?? virtualNetworkId, |
| 262 | createdAt: observed.createdAt, |
| 263 | }; |
| 264 | }), |
| 265 | delete: Effect.fn(function* ({ output }) { |
| 266 | yield* zeroTrust |
| 267 | .deleteNetworkRoute({ |
| 268 | accountId: output.accountId, |
| 269 | routeId: output.routeId, |
| 270 | }) |
| 271 | .pipe( |
| 272 | // Idempotent delete: distilled doesn't tag NotFound on |
| 273 | // teamnet/routes/{id}, so we swallow read-side failure |
| 274 | // wholesale. A "delete a deleted route" is not an error. |
| 275 | Effect.catch(() => Effect.succeed(undefined)), |
| 276 | ); |
| 277 | }), |
| 278 | list: Effect.fn(function* () { |
| 279 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 280 | // Account-scoped collection: enumerate every Zero Trust network |
| 281 | // route in the account, exhaustively paginating. Drop soft-deleted |
| 282 | // routes and anything missing an id, then hydrate into the exact |
| 283 | // `read` Attributes shape so each element is delete-ready. |
| 284 | return yield* zeroTrust.listNetworkRoutes |
| 285 | .pages({ accountId, isDeleted: false }) |
| 286 | .pipe( |
| 287 | Stream.runCollect, |
| 288 | Effect.map((chunk) => |
| 289 | Array.from(chunk).flatMap((page) => |
| 290 | (page.result ?? []) |
| 291 | .filter( |
| 292 | (r): r is typeof r & { id: string } => |
| 293 | r.id != null && !r.deletedAt, |
| 294 | ) |
| 295 | .map((r) => ({ |
| 296 | routeId: r.id, |
| 297 | network: normalize(r.network) ?? "", |
| 298 | tunnelId: normalize(r.tunnelId) ?? "", |
| 299 | accountId, |
| 300 | comment: normalize(r.comment), |
| 301 | virtualNetworkId: normalize(r.virtualNetworkId), |
| 302 | createdAt: normalize(r.createdAt), |
| 303 | })), |
| 304 | ), |
| 305 | ), |
| 306 | ); |
| 307 | }), |
| 308 | read: Effect.fn(function* ({ olds, output }) { |
| 309 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 310 | |
| 311 | const acct = output?.accountId ?? accountId; |
| 312 | const tunnelId = output?.tunnelId ?? olds?.tunnelId; |
| 313 | const network = output?.network ?? olds?.network; |
| 314 | const virtualNetworkId = |
| 315 | output?.virtualNetworkId ?? olds?.virtualNetworkId; |
| 316 | if (!tunnelId || !network) return undefined; |
| 317 | const observed = yield* observe( |
| 318 | acct, |
| 319 | network, |
| 320 | virtualNetworkId, |
| 321 | output?.routeId, |
| 322 | ); |
| 323 | if (!observed) return undefined; |
| 324 | return { |
| 325 | routeId: observed.id, |
| 326 | network: observed.network ?? network, |
| 327 | tunnelId: observed.tunnelId ?? tunnelId, |
| 328 | accountId: acct, |
| 329 | comment: observed.comment, |
| 330 | virtualNetworkId: observed.virtualNetworkId ?? virtualNetworkId, |
| 331 | createdAt: observed.createdAt, |
| 332 | }; |
| 333 | }), |
| 334 | }); |
| 335 | |
| 336 | // Route networks are unique per virtual network across the WHOLE |
| 337 | // account (Cloudflare rejects a duplicate CIDR with a Conflict no |
| 338 | // matter which tunnel it's attached to), so the scan must NOT be |
| 339 | // scoped to a tunnel — otherwise a route left on another tunnel is |
| 340 | // invisible to observation and the create Conflict is unrecoverable. |
| 341 | const findRouteByNetwork = ( |
| 342 | accountId: string, |
| 343 | network: string, |
| 344 | virtualNetworkId: string | undefined, |
| 345 | ) => |
| 346 | zeroTrust.listNetworkRoutes |
| 347 | .items({ |
| 348 | accountId, |
| 349 | isDeleted: false, |
| 350 | // subset + superset of the same CIDR == exact match |
| 351 | networkSubset: network, |
| 352 | networkSuperset: network, |
| 353 | }) |
| 354 | .pipe( |
| 355 | Stream.filter( |
| 356 | (r) => |
| 357 | !r.deletedAt && |
| 358 | r.network === network && |
| 359 | (virtualNetworkId === undefined || |
| 360 | r.virtualNetworkId === virtualNetworkId), |
| 361 | ), |
| 362 | Stream.runHead, |
| 363 | Effect.map(Option.getOrUndefined), |
| 364 | // The distilled cloudflare SDK only tags transport-shaped |
| 365 | // errors (Unauthorized / 5xx / TooManyRequests / parse |
| 366 | // errors) on this endpoint -- there's no `NotFound` or |
| 367 | // `Forbidden` tag to discriminate on. Mirror the |
| 368 | // canonical `Tunnel.ts` template: swallow read-side |
| 369 | // errors so observation falls through to "missing" and |
| 370 | // the ensure step can recover. |
| 371 | Effect.catch(() => Effect.succeed(undefined)), |
| 372 | ); |
| 373 | |
| 374 | const toObserved = (r: { |
| 375 | id?: string | null; |
| 376 | network?: string | null; |
| 377 | tunnelId?: string | null; |
| 378 | virtualNetworkId?: string | null; |
| 379 | comment?: string | null; |
| 380 | createdAt?: string | null; |
| 381 | deletedAt?: string | null; |
| 382 | }): ObservedRoute | undefined => |
| 383 | r.id |
| 384 | ? { |
| 385 | id: r.id, |
| 386 | network: normalize(r.network), |
| 387 | tunnelId: normalize(r.tunnelId), |
| 388 | virtualNetworkId: normalize(r.virtualNetworkId), |
| 389 | comment: normalize(r.comment), |
| 390 | createdAt: normalize(r.createdAt), |
| 391 | } |
| 392 | : undefined; |
| 393 | |
| 394 | const observe = Effect.fn(function* ( |
| 395 | acct: string, |
| 396 | network: string, |
| 397 | virtualNetworkId: string | undefined, |
| 398 | routeId: string | undefined, |
| 399 | ) { |
| 400 | if (routeId) { |
| 401 | // See `findRouteByNetwork` -- distilled doesn't tag |
| 402 | // NotFound on this endpoint, so we tolerate any read |
| 403 | // error and fall through to the list scan. |
| 404 | const raw = yield* zeroTrust |
| 405 | .getNetworkRoute({ accountId: acct, routeId }) |
| 406 | .pipe(Effect.catch((_: unknown) => Effect.succeed(undefined))); |
| 407 | const got = raw ? toObserved(raw) : undefined; |
| 408 | if (got) return got; |
| 409 | } |
| 410 | const match = yield* findRouteByNetwork(acct, network, virtualNetworkId); |
| 411 | if (!match) return undefined; |
| 412 | return toObserved(match); |
| 413 | }); |
| 414 | |
| 415 | type ObservedRoute = { |
| 416 | id: string; |
| 417 | network: string | undefined; |
| 418 | tunnelId: string | undefined; |
| 419 | virtualNetworkId: string | undefined; |
| 420 | comment: string | undefined; |
| 421 | createdAt: string | undefined; |
| 422 | }; |
| 423 | |
| 424 | const normalize = (v: string | null | undefined): string | undefined => |
| 425 | v == null ? undefined : v; |