| 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 * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | |
| 11 | /** |
| 12 | * Per-origin connection tuning the cloudflared daemon applies when |
| 13 | * forwarding requests upstream. Re-exports distilled's shape so every |
| 14 | * server-recognised knob (TLS verify, keep-alives, HTTP/2, Access-protected |
| 15 | * origin, etc.) is available without re-declaring the struct. |
| 16 | */ |
| 17 | export type OriginRequest = NonNullable< |
| 18 | NonNullable< |
| 19 | zeroTrust.PutTunnelCloudflaredConfigurationRequest["config"] |
| 20 | >["originRequest"] |
| 21 | >; |
| 22 | |
| 23 | /** |
| 24 | * One ingress rule in the tunnel's routing list. cloudflared matches each |
| 25 | * incoming connection against these in order; the first match wins. |
| 26 | * |
| 27 | * Omit `hostname` (and `path`) on the *final* rule to make it the catch-all |
| 28 | * — by convention a `service: "http_status:404"` entry. If you don't author |
| 29 | * a catch-all, the provider appends one for you so the tunnel config stays |
| 30 | * valid (Cloudflare rejects PUTs whose last rule has a hostname). |
| 31 | */ |
| 32 | export interface IngressRule { |
| 33 | /** |
| 34 | * Public or private hostname this rule matches. Omit for the catch-all |
| 35 | * rule (which must be last in the list). |
| 36 | */ |
| 37 | hostname?: string; |
| 38 | /** |
| 39 | * Upstream URL or pseudo-service. Common values: |
| 40 | * - `http://service.namespace.svc.cluster.local:80` — forward to a K8s Service |
| 41 | * - `https://10.0.0.5:8443` — forward to a private IP |
| 42 | * - `http_status:404` — return a literal status code (used for the catch-all) |
| 43 | * - `hello_world` — cloudflared's built-in test page |
| 44 | */ |
| 45 | service: string; |
| 46 | /** |
| 47 | * Optional URL path prefix the rule matches. When omitted, the rule |
| 48 | * matches every path for the hostname. |
| 49 | */ |
| 50 | path?: string; |
| 51 | /** |
| 52 | * Per-rule origin tuning. Merges with (and overrides) the resource-level |
| 53 | * `originRequest` defaults. |
| 54 | */ |
| 55 | originRequest?: OriginRequest; |
| 56 | } |
| 57 | |
| 58 | export interface ConfigurationProps { |
| 59 | /** |
| 60 | * UUID of the `cfd_tunnel` this configuration belongs to. Each tunnel |
| 61 | * has a single configuration document, so this both identifies the |
| 62 | * resource and gates lifecycle operations. |
| 63 | * |
| 64 | * Stable — changing the tunnel triggers replacement. |
| 65 | * |
| 66 | * Declared as plain `string` (not `string`) so it is statically |
| 67 | * knowable inside `diff` for the replacement check. |
| 68 | */ |
| 69 | tunnelId: string; |
| 70 | /** |
| 71 | * Ordered ingress rules. cloudflared evaluates them top-down and the |
| 72 | * first match wins. The catch-all rule is appended automatically — do |
| 73 | * not include a trailing `{ service: "http_status:404" }` here unless |
| 74 | * you want it to land mid-list. |
| 75 | */ |
| 76 | ingress: ReadonlyArray<IngressRule>; |
| 77 | /** |
| 78 | * Default origin-request tuning applied to every rule that doesn't set |
| 79 | * its own. Equivalent to the top-level `originRequest` block in a |
| 80 | * cloudflared YAML config. |
| 81 | */ |
| 82 | originRequest?: OriginRequest; |
| 83 | /** |
| 84 | * Service for the auto-appended catch-all rule. cloudflared requires |
| 85 | * the last rule to omit `hostname`; this controls what it returns when |
| 86 | * no earlier rule matches. |
| 87 | * |
| 88 | * @default "http_status:404" |
| 89 | */ |
| 90 | catchAllService?: string; |
| 91 | } |
| 92 | |
| 93 | export interface ConfigurationAttributes { |
| 94 | /** The tunnel this configuration belongs to. */ |
| 95 | tunnelId: string; |
| 96 | /** Account that owns the tunnel. */ |
| 97 | accountId: string; |
| 98 | /** |
| 99 | * Cloudflare-side config version. Bumped on every PUT, even when the |
| 100 | * payload is unchanged — surface it so downstream consumers can wait |
| 101 | * on a specific revision if needed. |
| 102 | */ |
| 103 | version: number | undefined; |
| 104 | } |
| 105 | |
| 106 | export type Configuration = Resource< |
| 107 | "Cloudflare.Tunnel.Configuration", |
| 108 | ConfigurationProps, |
| 109 | ConfigurationAttributes, |
| 110 | never, |
| 111 | Providers |
| 112 | >; |
| 113 | |
| 114 | /** |
| 115 | * Routing configuration for a remotely-managed Cloudflare Tunnel. |
| 116 | * |
| 117 | * Cloudflare exposes the cfd_tunnel configuration as a single PUT-style |
| 118 | * document per tunnel — `ingress` rules in order, plus optional default |
| 119 | * `originRequest` settings. This resource owns that document; it is the |
| 120 | * declarative equivalent of editing a tunnel's Public Hostname or Private |
| 121 | * Hostname rules in the Zero Trust dashboard. |
| 122 | * |
| 123 | * The catch-all rule (final ingress entry with no hostname) is appended |
| 124 | * automatically — Cloudflare rejects PUTs whose last rule has a hostname, |
| 125 | * and forgetting it is a common foot-gun. Override the auto-appended |
| 126 | * service via {@link ConfigurationProps.catchAllService}. |
| 127 | * ### Routing a private hostname through a tunnel |
| 128 | * **Example:** Map an internal admin UI through a Cloudflare Tunnel to a K8s Service |
| 129 | * ```typescript |
| 130 | * yield* Cloudflare.Tunnel.Configuration("AdminIngress", { |
| 131 | * tunnelId: tunnel.tunnelId, |
| 132 | * ingress: [ |
| 133 | * { |
| 134 | * hostname: "cluster-admin.microagi", |
| 135 | * service: "http://research-ui.admin.svc.cluster.local:80", |
| 136 | * }, |
| 137 | * ], |
| 138 | * }); |
| 139 | * ``` |
| 140 | * |
| 141 | * ### Multiple hostnames + custom catch-all |
| 142 | * **Example:** Two services on one tunnel, returning 503 for unknown hosts |
| 143 | * ```typescript |
| 144 | * yield* Cloudflare.Tunnel.Configuration("Ingress", { |
| 145 | * tunnelId: tunnel.tunnelId, |
| 146 | * ingress: [ |
| 147 | * { hostname: "ui.internal", service: "http://ui.app.svc.cluster.local:80" }, |
| 148 | * { hostname: "api.internal", service: "http://api.app.svc.cluster.local:8080" }, |
| 149 | * ], |
| 150 | * catchAllService: "http_status:503", |
| 151 | * }); |
| 152 | * ``` |
| 153 | * |
| 154 | * @resource |
| 155 | * @product Tunnels |
| 156 | * @category Cloudflare One (Zero Trust) |
| 157 | */ |
| 158 | export const Configuration = Resource<Configuration>( |
| 159 | "Cloudflare.Tunnel.Configuration", |
| 160 | ); |
| 161 | |
| 162 | // --------------------------------------------------------------------------- |
| 163 | // Observed-state types |
| 164 | // --------------------------------------------------------------------------- |
| 165 | |
| 166 | interface ObservedIngressRule { |
| 167 | readonly hostname?: string; |
| 168 | readonly service?: string; |
| 169 | readonly path?: string; |
| 170 | readonly originRequest?: Record<string, unknown>; |
| 171 | } |
| 172 | |
| 173 | interface ObservedConfig { |
| 174 | readonly ingress?: ReadonlyArray<ObservedIngressRule>; |
| 175 | readonly originRequest?: Record<string, unknown>; |
| 176 | readonly version?: number; |
| 177 | } |
| 178 | |
| 179 | const undef = <T>(v: T | null | undefined): T | undefined => |
| 180 | v == null ? undefined : v; |
| 181 | |
| 182 | const stripNulls = (v: unknown): unknown => { |
| 183 | if (Array.isArray(v)) return v.map(stripNulls); |
| 184 | if (v !== null && typeof v === "object") { |
| 185 | const out: Record<string, unknown> = {}; |
| 186 | for (const [k, val] of Object.entries(v as Record<string, unknown>)) { |
| 187 | if (val === null || val === undefined) continue; |
| 188 | out[k] = stripNulls(val); |
| 189 | } |
| 190 | return out; |
| 191 | } |
| 192 | return v; |
| 193 | }; |
| 194 | |
| 195 | const narrowConfig = (raw: { |
| 196 | config?: { |
| 197 | ingress?: ReadonlyArray<{ |
| 198 | hostname?: string | null; |
| 199 | service?: string; |
| 200 | path?: string | null; |
| 201 | originRequest?: Record<string, unknown> | null; |
| 202 | }> | null; |
| 203 | originRequest?: Record<string, unknown> | null; |
| 204 | } | null; |
| 205 | version?: number | null; |
| 206 | }): ObservedConfig => ({ |
| 207 | ingress: |
| 208 | raw.config?.ingress == null |
| 209 | ? undefined |
| 210 | : raw.config.ingress.map((r) => ({ |
| 211 | hostname: undef(r.hostname), |
| 212 | service: r.service, |
| 213 | path: undef(r.path), |
| 214 | originRequest: undef(r.originRequest), |
| 215 | })), |
| 216 | originRequest: undef(raw.config?.originRequest), |
| 217 | version: undef(raw.version), |
| 218 | }); |
| 219 | |
| 220 | // --------------------------------------------------------------------------- |
| 221 | // Body construction |
| 222 | // --------------------------------------------------------------------------- |
| 223 | |
| 224 | const DEFAULT_CATCH_ALL_SERVICE = "http_status:404"; |
| 225 | |
| 226 | const buildIngress = ( |
| 227 | rules: ReadonlyArray<IngressRule>, |
| 228 | catchAllService: string, |
| 229 | ): Array<IngressRule> => { |
| 230 | const out: Array<IngressRule> = []; |
| 231 | for (const r of rules) { |
| 232 | // Drop any catch-all the caller threaded into the middle — we always |
| 233 | // append exactly one trailing catch-all of our own, and a mid-list |
| 234 | // catch-all would short-circuit every following rule anyway. |
| 235 | if (r.hostname === undefined && r.path === undefined) continue; |
| 236 | out.push(r); |
| 237 | } |
| 238 | out.push({ service: catchAllService }); |
| 239 | return out; |
| 240 | }; |
| 241 | |
| 242 | // --------------------------------------------------------------------------- |
| 243 | // Drift detection |
| 244 | // |
| 245 | // Cloudflare echoes back a config object with every optional field filled in |
| 246 | // (often as `null`). Strip nulls before comparing so a server-supplied |
| 247 | // `path: null` doesn't endlessly diff against an unset desired `path`. |
| 248 | // --------------------------------------------------------------------------- |
| 249 | |
| 250 | const configsEqual = ( |
| 251 | desiredIngress: ReadonlyArray<IngressRule>, |
| 252 | desiredOrigin: OriginRequest | undefined, |
| 253 | observed: ObservedConfig, |
| 254 | ): boolean => { |
| 255 | const left = stripNulls({ |
| 256 | ingress: desiredIngress, |
| 257 | originRequest: desiredOrigin, |
| 258 | }); |
| 259 | const right = stripNulls({ |
| 260 | ingress: observed.ingress ?? [], |
| 261 | originRequest: observed.originRequest, |
| 262 | }); |
| 263 | return JSON.stringify(left) === JSON.stringify(right); |
| 264 | }; |
| 265 | |
| 266 | // --------------------------------------------------------------------------- |
| 267 | // Provider |
| 268 | // --------------------------------------------------------------------------- |
| 269 | |
| 270 | export const ConfigurationProvider = () => |
| 271 | Provider.effect( |
| 272 | Configuration, |
| 273 | Effect.gen(function* () { |
| 274 | const env = yield* CloudflareEnvironment; |
| 275 | |
| 276 | const getConfig = yield* zeroTrust.getTunnelCloudflaredConfiguration; |
| 277 | const putConfig = yield* zeroTrust.putTunnelCloudflaredConfiguration; |
| 278 | |
| 279 | const observe = (accountId: string, tunnelId: string) => |
| 280 | Effect.gen(function* () { |
| 281 | // A tunnel with no configuration yet surfaces as the tagged |
| 282 | // `TunnelConfigurationNotFound` (code 1055) — swallow into "missing". |
| 283 | const r = yield* getConfig({ accountId, tunnelId }).pipe( |
| 284 | Effect.catchTag("TunnelConfigurationNotFound", () => |
| 285 | Effect.succeed(undefined), |
| 286 | ), |
| 287 | ); |
| 288 | if (r === undefined) return undefined; |
| 289 | return narrowConfig(r as Parameters<typeof narrowConfig>[0]); |
| 290 | }); |
| 291 | |
| 292 | return { |
| 293 | stables: ["tunnelId", "accountId"], |
| 294 | |
| 295 | diff: Effect.fn(function* ({ olds = {}, news }) { |
| 296 | // tunnelId is statically declared as Input<string>; by reconcile |
| 297 | // time both sides resolve to strings. A change replaces because |
| 298 | // a tunnel's configuration is keyed by tunnelId in the URL. |
| 299 | const oldId = (olds as ConfigurationProps).tunnelId; |
| 300 | const newId = (news as ConfigurationProps).tunnelId; |
| 301 | if ( |
| 302 | typeof oldId === "string" && |
| 303 | typeof newId === "string" && |
| 304 | oldId !== newId |
| 305 | ) { |
| 306 | return { action: "replace" } as const; |
| 307 | } |
| 308 | }), |
| 309 | |
| 310 | reconcile: Effect.fn(function* ({ news }) { |
| 311 | const { accountId } = yield* env; |
| 312 | // Inputs have been resolved to concrete strings by the Plan layer. |
| 313 | const tunnelId = news.tunnelId as string; |
| 314 | const catchAllService = |
| 315 | news.catchAllService ?? DEFAULT_CATCH_ALL_SERVICE; |
| 316 | const desiredIngress = buildIngress(news.ingress, catchAllService); |
| 317 | |
| 318 | // Observe — falls through to a PUT if the tunnel has no config yet. |
| 319 | let observed = yield* observe(accountId, tunnelId); |
| 320 | |
| 321 | // Sync — skip the PUT entirely when observed equals desired. |
| 322 | if ( |
| 323 | observed === undefined || |
| 324 | !configsEqual(desiredIngress, news.originRequest, observed) |
| 325 | ) { |
| 326 | const updated = yield* putConfig({ |
| 327 | accountId, |
| 328 | tunnelId, |
| 329 | config: { |
| 330 | ingress: desiredIngress.map((r) => ({ |
| 331 | hostname: r.hostname, |
| 332 | service: r.service, |
| 333 | path: r.path, |
| 334 | originRequest: r.originRequest, |
| 335 | })), |
| 336 | originRequest: news.originRequest, |
| 337 | }, |
| 338 | }); |
| 339 | observed = narrowConfig( |
| 340 | updated as Parameters<typeof narrowConfig>[0], |
| 341 | ); |
| 342 | } |
| 343 | |
| 344 | return { |
| 345 | tunnelId, |
| 346 | accountId, |
| 347 | version: observed.version, |
| 348 | } satisfies ConfigurationAttributes; |
| 349 | }), |
| 350 | |
| 351 | delete: Effect.fn(function* ({ output }) { |
| 352 | // Cloudflare's API has no DELETE for the configuration document — |
| 353 | // PUT the catch-all-only shape so the tunnel stops routing |
| 354 | // anything and idempotently converges. |
| 355 | yield* putConfig({ |
| 356 | accountId: output.accountId, |
| 357 | tunnelId: output.tunnelId, |
| 358 | config: { |
| 359 | ingress: [{ service: DEFAULT_CATCH_ALL_SERVICE }], |
| 360 | }, |
| 361 | }).pipe(Effect.catch(() => Effect.void)); |
| 362 | }), |
| 363 | |
| 364 | read: Effect.fn(function* ({ output }) { |
| 365 | if (!output) return undefined; |
| 366 | const observed = yield* observe(output.accountId, output.tunnelId); |
| 367 | if (observed === undefined) return undefined; |
| 368 | return { |
| 369 | tunnelId: output.tunnelId, |
| 370 | accountId: output.accountId, |
| 371 | version: observed.version, |
| 372 | } satisfies ConfigurationAttributes; |
| 373 | }), |
| 374 | |
| 375 | // The configuration is a per-tunnel singleton with no account-wide |
| 376 | // enumeration API — fan out from the parent. Enumerate every |
| 377 | // (non-deleted) cfd_tunnel in the account, then read each tunnel's |
| 378 | // configuration document with bounded concurrency. Tunnels without a |
| 379 | // config (`TunnelConfigurationNotFound`, handled inside `observe`) are |
| 380 | // skipped, exactly mirroring `read`. |
| 381 | list: () => |
| 382 | Effect.gen(function* () { |
| 383 | const { accountId } = yield* env; |
| 384 | |
| 385 | const tunnelIds = yield* zeroTrust.listTunnelCloudflareds |
| 386 | .pages({ accountId, isDeleted: false }) |
| 387 | .pipe( |
| 388 | Stream.runCollect, |
| 389 | Effect.map((chunk) => |
| 390 | Array.from(chunk).flatMap((page) => |
| 391 | (page.result ?? []).flatMap((t) => |
| 392 | t.id != null && t.deletedAt == null ? [t.id] : [], |
| 393 | ), |
| 394 | ), |
| 395 | ), |
| 396 | ); |
| 397 | |
| 398 | const rows = yield* Effect.forEach( |
| 399 | tunnelIds, |
| 400 | (tunnelId) => |
| 401 | observe(accountId, tunnelId).pipe( |
| 402 | Effect.map((observed) => |
| 403 | observed === undefined |
| 404 | ? undefined |
| 405 | : ({ |
| 406 | tunnelId, |
| 407 | accountId, |
| 408 | version: observed.version, |
| 409 | } satisfies ConfigurationAttributes), |
| 410 | ), |
| 411 | ), |
| 412 | { concurrency: 10 }, |
| 413 | ); |
| 414 | |
| 415 | return rows.filter( |
| 416 | (row): row is ConfigurationAttributes => row !== undefined, |
| 417 | ); |
| 418 | }), |
| 419 | }; |
| 420 | }), |
| 421 | ); |
| 422 | |
| 423 | void Option.none; |