1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15export type TunnelProps = {
16 /**
17 * Name for the tunnel. If omitted, a unique name will be generated.
18 *
19 * Tunnel names are immutable -- changing the name triggers replacement.
20 *
21 * @default ${app}-${stage}-${id}
22 */
23 name?: string;
24 /**
25 * Secret used by the tunnel connector. If omitted, Cloudflare generates one.
26 * Must be at least 32 bytes encoded as base64.
27 */
28 tunnelSecret?: Redacted.Redacted<string>;
29 /**
30 * Where the tunnel configuration lives.
31 * - `"cloudflare"` - managed remotely via the API (default)
32 * - `"local"` - managed via a YAML file on the origin
33 *
34 * @default "cloudflare"
35 */
36 configSrc?: "cloudflare" | "local";
37 /**
38 * Ingress rules describing how requests are routed. Must end with a
39 * catch-all rule (e.g. `{ service: "http_status:404" }`). Only honored when
40 * `configSrc` is `"cloudflare"`.
41 */
42 ingress?: Tunnel.IngressRule[];
43 /**
44 * Origin request configuration applied to all rules. Only honored when
45 * `configSrc` is `"cloudflare"`.
46 */
47 originRequest?: Tunnel.OriginRequestConfig;
48 /**
49 * Whether to adopt an existing tunnel with the same name when create fails.
50 *
51 * @default false
52 */
53 adopt?: boolean;
54};
55
56export declare namespace Tunnel {
57 /**
58 * Ingress rule describing how a hostname or path is routed.
59 */
60 export interface IngressRule {
61 hostname?: string;
62 service: string;
63 path?: string;
64 originRequest?: OriginRequestConfig;
65 }
66 /**
67 * Origin request configuration applied per-rule or globally.
68 */
69 export interface OriginRequestConfig {
70 connectTimeout?: number;
71 tlsTimeout?: number;
72 tcpKeepAlive?: number;
73 noHappyEyeballs?: boolean;
74 keepAliveConnections?: number;
75 keepAliveTimeout?: number;
76 http2Origin?: boolean;
77 httpHostHeader?: string;
78 caPool?: string;
79 noTLSVerify?: boolean;
80 disableChunkedEncoding?: boolean;
81 proxyType?: string;
82 matchSNItoHost?: boolean;
83 originServerName?: string;
84 }
85}
86
87export type Tunnel = Resource<
88 "Cloudflare.Tunnel.Tunnel",
89 TunnelProps,
90 {
91 tunnelId: string;
92 tunnelName: string;
93 accountTag: string | undefined;
94 accountId: string;
95 createdAt: string | undefined;
96 deletedAt: string | undefined;
97 configSrc: "cloudflare" | "local";
98 token: Redacted.Redacted<string>;
99 },
100 never,
101 Providers
102>;
103
104/**
105 * A Cloudflare Tunnel that establishes a secure connection from your origin to
106 * Cloudflare's edge.
107 * ### Creating a Tunnel
108 * **Example:** Basic tunnel
109 * ```typescript
110 * const tunnel = yield* Cloudflare.Tunnel.Tunnel("MyTunnel");
111 * // Run the connector with: cloudflared tunnel run --token <Redacted.value(tunnel.token)>
112 * ```
113 *
114 * **Example:** Tunnel with ingress rules
115 * ```typescript
116 * const tunnel = yield* Cloudflare.Tunnel.Tunnel("Web", {
117 * ingress: [
118 * { hostname: "app.example.com", service: "http://localhost:3000" },
119 * { service: "http_status:404" },
120 * ],
121 * });
122 * ```
123 *
124 * ### Managing Tunnels at Runtime
125 * The `Tunnel` resource manages a single, statically-declared tunnel as part of
126 * a stack. To create, read, update, or delete tunnels *on the fly* from inside
127 * a deployed Worker, bind one of the runtime tunnel clients instead. Each
128 * provisions a least-privilege {@link AccountApiToken} and injects it into the
129 * Worker:
130 *
131 * - {@link ReadTunnel} — read-only (`get`, `list`, `getToken`,
132 * `getConfiguration`); scoped to `Cloudflare Tunnel Read`.
133 * - {@link WriteTunnel} — mutating (`create`, `update`, `delete`,
134 * `putConfiguration`); scoped to `Cloudflare Tunnel Write`.
135 * - {@link ReadWriteTunnel} — the full CRUD surface; scoped to both.
136 *
137 * **Example:** Create a tunnel on demand from a Worker
138 * ```typescript
139 * // init
140 * const tunnels = yield* Cloudflare.Tunnel.ReadWriteTunnel();
141 *
142 * return {
143 * fetch: Effect.gen(function* () {
144 * const tunnel = yield* tunnels.create({ name: "on-demand-tunnel" });
145 * const token = yield* tunnels.getToken(tunnel.id!);
146 * return HttpServerResponse.json({ id: tunnel.id, token });
147 * }),
148 * };
149 * ```
150 *
151 * @resource
152 * @product Tunnels
153 * @category Cloudflare One (Zero Trust)
154 */
155export const Tunnel = Resource<Tunnel>("Cloudflare.Tunnel.Tunnel", {
156 aliases: ["Cloudflare.Tunnel"],
157});
158
159export const TunnelProvider = () =>
160 Provider.succeed(Tunnel, {
161 stables: ["tunnelId", "accountTag", "accountId"],
162 // Account collection: enumerate every cfd_tunnel in the account, skip
163 // deleted tunnels (match `read`/`findTunnelByName`), exhaustively
164 // paginate, then hydrate each into the exact `read` Attributes shape.
165 // The token is fetched per-tunnel with bounded concurrency; a tunnel
166 // whose token is gone is dropped (typed per-item not-found).
167 list: Effect.fn(function* () {
168 const { accountId } = yield* yield* CloudflareEnvironment;
169 const tunnels = yield* zeroTrust.listTunnels
170 .pages({ accountId, isDeleted: false, tunTypes: ["cfd_tunnel"] })
171 .pipe(
172 Stream.runCollect,
173 Effect.map((chunk) =>
174 Array.from(chunk).flatMap((page) =>
175 (page.result ?? []).filter(
176 (t): t is typeof t & { id: string } => !!t.id && !t.deletedAt,
177 ),
178 ),
179 ),
180 );
181 const rows = yield* Effect.forEach(
182 tunnels,
183 (t) =>
184 zeroTrust
185 .getTunnelCloudflaredToken({ accountId, tunnelId: t.id })
186 .pipe(
187 Effect.map((token): Tunnel["Attributes"] => ({
188 tunnelId: t.id,
189 tunnelName: t.name ?? t.id,
190 accountTag: t.accountTag ?? undefined,
191 accountId,
192 createdAt: t.createdAt ?? undefined,
193 deletedAt: t.deletedAt ?? undefined,
194 configSrc: ((t as { configSrc?: "cloudflare" | "local" | null })
195 .configSrc ?? "cloudflare") as "cloudflare" | "local",
196 token: Redacted.make(token),
197 })),
198 Effect.catchTag("TunnelTokenNotFound", () =>
199 Effect.succeed(undefined),
200 ),
201 ),
202 { concurrency: 10 },
203 );
204 return rows.filter(
205 (row): row is Tunnel["Attributes"] => row !== undefined,
206 );
207 }),
208 diff: Effect.fn(function* ({ id, olds = {}, news, output }) {
209 const { accountId } = yield* yield* CloudflareEnvironment;
210 if (!isResolved(news)) return undefined;
211 if ((output?.accountId ?? accountId) !== accountId) {
212 return { action: "replace" } as const;
213 }
214 const oldName =
215 output?.tunnelName ?? (yield* createTunnelName(id, olds.name));
216 // Auto-generated names are engine-owned: the deployed name stays
217 // authoritative even if the generator would name this id differently
218 // today. Only an explicit user-provided name can force a replace.
219 const name = news.name ?? oldName;
220 if (name !== oldName) {
221 return { action: "replace" } as const;
222 }
223 const oldSecret = olds.tunnelSecret
224 ? Redacted.value(olds.tunnelSecret)
225 : undefined;
226 const newSecret = news.tunnelSecret
227 ? Redacted.value(news.tunnelSecret)
228 : undefined;
229 if (oldSecret !== newSecret) {
230 return { action: "replace" } as const;
231 }
232 if (
233 (olds.configSrc ?? "cloudflare") !== (news.configSrc ?? "cloudflare")
234 ) {
235 return { action: "replace" } as const;
236 }
237 }),
238 reconcile: Effect.fn(function* ({ id, news = {}, output }) {
239 const { accountId } = yield* yield* CloudflareEnvironment;
240 // Prefer the deployed name: regenerating would target a different
241 // resource if the generator's output for this id ever drifts.
242 const name = yield* createTunnelName(id, news.name ?? output?.tunnelName);
243 const configSrc = news.configSrc ?? output?.configSrc ?? "cloudflare";
244 const tunnelSecret = news.tunnelSecret
245 ? Redacted.value(news.tunnelSecret)
246 : undefined;
247 const acct = output?.accountId ?? accountId;
248
249 // Observe — re-fetch the cached tunnel; fall back to a name
250 // lookup so we recover from out-of-band deletes or partial
251 // state-persistence failures.
252 let observed:
253 | {
254 id?: string | null;
255 name?: string | null;
256 accountTag?: string | null;
257 createdAt?: string | null;
258 deletedAt?: string | null;
259 }
260 | undefined;
261 if (output?.tunnelId) {
262 observed = yield* zeroTrust
263 .getTunnelCloudflared({
264 accountId: acct,
265 tunnelId: output.tunnelId,
266 })
267 .pipe(Effect.catch(() => Effect.succeed(undefined)));
268 }
269 if (!observed) {
270 observed = yield* findTunnelByName(name);
271 }
272
273 // Ensure — create if missing. Cloudflare rejects a duplicate
274 // name with a generic error; tolerate by adopting the
275 // existing tunnel when the caller opted into adoption.
276 if (!observed || !observed.id) {
277 observed = yield* zeroTrust
278 .createTunnelCloudflared({
279 accountId: acct,
280 name,
281 configSrc,
282 tunnelSecret,
283 })
284 .pipe(
285 Effect.catch((err) =>
286 Effect.gen(function* () {
287 if (!news.adopt) return yield* Effect.fail(err);
288 const existing = yield* findTunnelByName(name);
289 if (!existing || !existing.id) {
290 return yield* Effect.fail(err);
291 }
292 return existing;
293 }),
294 ),
295 );
296 }
297
298 // Sync — when the tunnel is managed in Cloudflare, push the
299 // desired ingress + originRequest configuration. The PUT is
300 // idempotent: equal payloads converge to the same state, so we
301 // always push to apply drift.
302 if (configSrc !== "local") {
303 yield* writeConfiguration(
304 observed.id!,
305 news.ingress,
306 news.originRequest,
307 );
308 }
309
310 const token = yield* zeroTrust.getTunnelCloudflaredToken({
311 accountId: acct,
312 tunnelId: observed.id!,
313 });
314
315 return {
316 tunnelId: observed.id!,
317 tunnelName: observed.name ?? name,
318 accountTag: observed.accountTag ?? undefined,
319 accountId: acct,
320 createdAt: observed.createdAt ?? undefined,
321 deletedAt: observed.deletedAt ?? undefined,
322 configSrc,
323 token: Redacted.make(token),
324 };
325 }),
326 delete: Effect.fn(function* ({ output }) {
327 // Observe — an already-(soft-)deleted tunnel means nothing to do.
328 const observed = yield* zeroTrust
329 .getTunnelCloudflared({
330 accountId: output.accountId,
331 tunnelId: output.tunnelId,
332 })
333 .pipe(
334 Effect.catchTag("TunnelNotFound", () => Effect.succeed(undefined)),
335 );
336 if (observed === undefined || observed.deletedAt != null) return;
337 // Delete — sibling route/config deletions propagate asynchronously and
338 // Cloudflare transiently rejects the tunnel delete while they drain.
339 // Retry bounded and let a persistent failure surface: a swallowed
340 // failure silently leaks the tunnel.
341 yield* zeroTrust
342 .deleteTunnelCloudflared({
343 accountId: output.accountId,
344 tunnelId: output.tunnelId,
345 })
346 .pipe(
347 Effect.retry({
348 schedule: Schedule.max([
349 Schedule.spaced("3 seconds"),
350 Schedule.recurs(10),
351 ]),
352 }),
353 );
354 }),
355 read: Effect.fn(function* ({ id, output, olds }) {
356 const { accountId } = yield* yield* CloudflareEnvironment;
357 if (output?.tunnelId) {
358 return yield* zeroTrust
359 .getTunnelCloudflared({
360 accountId: output.accountId,
361 tunnelId: output.tunnelId,
362 })
363 .pipe(
364 Effect.flatMap((t) =>
365 zeroTrust
366 .getTunnelCloudflaredToken({
367 accountId: output.accountId,
368 tunnelId: output.tunnelId,
369 })
370 .pipe(
371 Effect.map((token) => ({
372 tunnelId: t.id ?? output.tunnelId,
373 tunnelName: t.name ?? output.tunnelName,
374 accountTag: t.accountTag ?? output.accountTag,
375 accountId: output.accountId,
376 createdAt: t.createdAt ?? output.createdAt,
377 deletedAt: t.deletedAt ?? output.deletedAt,
378 configSrc: ((
379 t as { configSrc?: "cloudflare" | "local" | null }
380 ).configSrc ??
381 output.configSrc ??
382 "cloudflare") as "cloudflare" | "local",
383 token: Redacted.make(token),
384 })),
385 ),
386 ),
387 Effect.catch(() => Effect.succeed(undefined)),
388 );
389 }
390 const name = yield* createTunnelName(id, olds?.name);
391 const existing = yield* findTunnelByName(name);
392 if (!existing || !existing.id) return undefined;
393
394 const token = yield* zeroTrust.getTunnelCloudflaredToken({
395 accountId,
396 tunnelId: existing.id,
397 });
398 return {
399 tunnelId: existing.id,
400 tunnelName: existing.name ?? name,
401 accountTag: existing.accountTag ?? undefined,
402 accountId,
403 createdAt: existing.createdAt ?? undefined,
404 deletedAt: existing.deletedAt ?? undefined,
405 configSrc: ((existing as { configSrc?: "cloudflare" | "local" | null })
406 .configSrc ?? "cloudflare") as "cloudflare" | "local",
407 token: Redacted.make(token),
408 };
409 }),
410 });
411
412const createTunnelName = (id: string, name: string | undefined) =>
413 Effect.gen(function* () {
414 if (name) return name;
415 return yield* createPhysicalName({ id });
416 });
417
418const writeConfiguration = (
419 tunnelId: string,
420 ingress: Tunnel.IngressRule[] | undefined,
421 originRequest: Tunnel.OriginRequestConfig | undefined,
422) =>
423 Effect.gen(function* () {
424 if (!ingress && !originRequest) return;
425 const { accountId } = yield* yield* CloudflareEnvironment;
426 yield* zeroTrust.putTunnelCloudflaredConfiguration({
427 accountId,
428 tunnelId,
429 config: { ingress, originRequest },
430 });
431 });
432
433const findTunnelByName = Effect.fn(function* (name: string) {
434 const { accountId } = yield* yield* CloudflareEnvironment;
435 return yield* zeroTrust.listTunnels
436 .items({
437 accountId,
438 name,
439 isDeleted: false,
440 tunTypes: ["cfd_tunnel"],
441 })
442 .pipe(
443 Stream.filter((t) => t.name === name && !t.deletedAt),
444 Stream.runHead,
445 Effect.map(Option.getOrUndefined),
446 );
447});