1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import 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 */
17export 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 */
32export 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
58export 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
93export 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
106export 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 */
158export const Configuration = Resource<Configuration>(
159 "Cloudflare.Tunnel.Configuration",
160);
161
162// ---------------------------------------------------------------------------
163// Observed-state types
164// ---------------------------------------------------------------------------
165
166interface ObservedIngressRule {
167 readonly hostname?: string;
168 readonly service?: string;
169 readonly path?: string;
170 readonly originRequest?: Record<string, unknown>;
171}
172
173interface ObservedConfig {
174 readonly ingress?: ReadonlyArray<ObservedIngressRule>;
175 readonly originRequest?: Record<string, unknown>;
176 readonly version?: number;
177}
178
179const undef = <T>(v: T | null | undefined): T | undefined =>
180 v == null ? undefined : v;
181
182const 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
195const 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
224const DEFAULT_CATCH_ALL_SERVICE = "http_status:404";
225
226const 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
250const 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
270export 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
423void Option.none;