1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
7import type { Providers } from "../Providers.ts";
8
9/**
10 * Configuration for the singleton WARP default device profile.
11 *
12 * Every Cloudflare Zero Trust account has exactly one default device
13 * profile that applies to all WARP devices not matched by a custom
14 * profile. This shape mirrors the fields supported by
15 * `PATCH /accounts/{accountId}/devices/policy` plus the three list
16 * endpoints for the split-tunnel include/exclude and fallback domains.
17 */
18export interface DeviceDefaultProfileProps {
19 /**
20 * Split-tunnel mode. `"include"` routes only listed CIDRs/hostnames
21 * through WARP; `"exclude"` routes everything except listed entries.
22 *
23 * The Cloudflare backend stores the include and exclude lists
24 * independently — only the list matching the active mode is enforced
25 * at runtime. We always push both lists when supplied so toggling
26 * `mode` is non-destructive.
27 *
28 * @default "exclude"
29 */
30 mode?: "include" | "exclude";
31 /**
32 * Routes that WARP will tunnel. Only effective when {@link mode} is
33 * `"include"`. Pushed to `PUT /devices/policy/include`.
34 */
35 splitTunnelInclude?: DeviceDefaultProfile.SplitTunnelEntry[];
36 /**
37 * Routes that WARP will bypass. Only effective when {@link mode} is
38 * `"exclude"`. Pushed to `PUT /devices/policy/exclude`.
39 */
40 splitTunnelExclude?: DeviceDefaultProfile.SplitTunnelEntry[];
41 /**
42 * Local-fallback DNS suffixes. For each suffix, WARP will resolve
43 * matching names via the given DNS servers (or the system resolver
44 * when omitted and {@link disableAutoFallback} is `false`).
45 */
46 fallbackDomains?: DeviceDefaultProfile.FallbackDomain[];
47 /**
48 * Seconds to wait before activating the captive-portal bypass. A
49 * value of `0` disables the bypass.
50 */
51 captivePortal?: number;
52 /**
53 * Seconds to wait before WARP reconnects after the user disables it.
54 * `0` keeps WARP disabled until the user re-enables it.
55 */
56 autoConnect?: number;
57 /**
58 * Whether the user is allowed to remove the device from the team.
59 */
60 allowedToLeave?: boolean;
61 /**
62 * Whether the user is allowed to switch WARP between Gateway-with-WARP
63 * and other modes.
64 */
65 allowModeSwitch?: boolean;
66 /**
67 * Whether WARP should surface available client updates to the user.
68 */
69 allowUpdates?: boolean;
70 /**
71 * When `true`, fallback domains without an explicit `dnsServer` are
72 * NOT resolved via the system resolver — WARP refuses to resolve them.
73 */
74 disableAutoFallback?: boolean;
75 /**
76 * When `true`, Microsoft 365 service IP ranges are added to the
77 * split-tunnel exclude list automatically.
78 */
79 excludeOfficeIps?: boolean;
80 /**
81 * When `true`, the user cannot turn the WARP switch off.
82 */
83 switchLocked?: boolean;
84 /**
85 * Selects WARP's tunneling behavior:
86 * - `"warp"` — full WireGuard tunnel
87 * - `"proxy"` — local SOCKS proxy on the chosen `port`
88 */
89 serviceModeV2?: DeviceDefaultProfile.ServiceModeV2;
90 /**
91 * Minutes of LAN access permitted after a WARP reconnect. `0` means
92 * unrestricted until the next WARP cycle.
93 */
94 lanAllowMinutes?: number;
95 /**
96 * Prefix length of the LAN subnet that {@link lanAllowMinutes}
97 * applies to (e.g. `24` for a `/24`).
98 */
99 lanAllowSubnetSize?: number;
100 /**
101 * When `true`, the OS registers WARP's local interface IP with the
102 * on-premises DNS server.
103 */
104 registerInterfaceIpWithDns?: boolean;
105 /**
106 * When `true`, WARP signals SCCM that the device is inside a VPN
107 * boundary. Windows only.
108 */
109 sccmVpnBoundarySupport?: boolean;
110 /**
111 * URL launched when the user clicks "Send Feedback" in the client.
112 */
113 supportUrl?: string;
114 /**
115 * Which underlying tunnel protocol WARP should use (e.g. `"wireguard"`,
116 * `"masque"`).
117 */
118 tunnelProtocol?: string;
119}
120
121export declare namespace DeviceDefaultProfile {
122 /**
123 * One entry in either the split-tunnel include or exclude list.
124 * Either an `address` (CIDR) or a `host` (DNS name) is required.
125 */
126 export interface SplitTunnelEntry {
127 /** CIDR-form route, e.g. `"10.0.0.0/8"` or `"1.2.3.4/32"`. */
128 address: string;
129 /** Hostname-form route, e.g. `"example.com"`. */
130 host?: string;
131 /** Free-form description shown in the dashboard. */
132 description?: string;
133 }
134 /**
135 * Local-fallback DNS configuration for a given DNS suffix.
136 */
137 export interface FallbackDomain {
138 /** DNS suffix, e.g. `"corp.example.com"`. */
139 suffix: string;
140 /** Optional free-form description. */
141 description?: string;
142 /** DNS resolvers (IPv4/IPv6) used for names matching `suffix`. */
143 dnsServer?: string[];
144 }
145 /**
146 * Tunneling-mode configuration.
147 */
148 export interface ServiceModeV2 {
149 /** `"warp"` for the full WireGuard tunnel, `"proxy"` for SOCKS. */
150 mode: "warp" | "proxy";
151 /** Local SOCKS port when `mode === "proxy"`. Ignored otherwise. */
152 port?: number;
153 }
154}
155
156export type DeviceDefaultProfile = Resource<
157 "Cloudflare.Devices.DefaultProfile",
158 DeviceDefaultProfileProps,
159 {
160 /** Account that owns the default profile. */
161 accountId: string;
162 /** Observed split-tunnel mode. */
163 mode: "include" | "exclude";
164 /** Observed include list. */
165 splitTunnelInclude: DeviceDefaultProfile.SplitTunnelEntry[];
166 /** Observed exclude list. */
167 splitTunnelExclude: DeviceDefaultProfile.SplitTunnelEntry[];
168 /** Observed fallback domains. */
169 fallbackDomains: DeviceDefaultProfile.FallbackDomain[];
170 /** Observed captive-portal timeout (seconds). */
171 captivePortal: number | undefined;
172 /** Observed auto-connect timeout (seconds). */
173 autoConnect: number | undefined;
174 /** Whether devices are allowed to leave the organization. */
175 allowedToLeave: boolean | undefined;
176 /** Whether the user may switch WARP modes. */
177 allowModeSwitch: boolean | undefined;
178 /** Whether update notifications are shown. */
179 allowUpdates: boolean | undefined;
180 /** Whether auto-fallback to system DNS is disabled. */
181 disableAutoFallback: boolean | undefined;
182 /** Whether Microsoft IPs are auto-excluded. */
183 excludeOfficeIps: boolean | undefined;
184 /** Whether the WARP switch is locked on. */
185 switchLocked: boolean | undefined;
186 /** Tunneling mode configuration. */
187 serviceModeV2: DeviceDefaultProfile.ServiceModeV2 | undefined;
188 /** LAN-access allowance (minutes). */
189 lanAllowMinutes: number | undefined;
190 /** LAN subnet prefix length. */
191 lanAllowSubnetSize: number | undefined;
192 /** Whether the WARP IP is registered with the on-prem DNS server. */
193 registerInterfaceIpWithDns: boolean | undefined;
194 /** Whether SCCM VPN-boundary support is enabled. */
195 sccmVpnBoundarySupport: boolean | undefined;
196 /** Send-Feedback URL. */
197 supportUrl: string | undefined;
198 /** Tunnel protocol selection. */
199 tunnelProtocol: string | undefined;
200 },
201 never,
202 Providers
203>;
204
205/**
206 * Manages the **singleton** Cloudflare WARP **default device profile** for
207 * an account. The default profile applies to every WARP device not
208 * matched by a custom profile.
209 * @remarks
210 * There is exactly one default profile per account; it cannot be created
211 * or deleted. Reconciling this resource patches the existing profile in
212 * place and synchronizes the four sibling list endpoints (include,
213 * exclude, fallback domains). The `delete` lifecycle is a deliberate
214 * no-op — destroying the Alchemy resource only removes our local state,
215 * the cloud profile remains intact.
216 *
217 * Custom (non-default) profiles are a separate resource.
218 *
219 * ### Configuring split tunneling
220 * **Example:** Exclude-mode (default): tunnel everything except listed routes
221 * ```typescript
222 * yield* Cloudflare.Devices.DeviceDefaultProfile("Default", {
223 * mode: "exclude",
224 * splitTunnelExclude: [
225 * { address: "10.0.0.0/8", description: "RFC1918" },
226 * { address: "192.168.0.0/16", description: "RFC1918" },
227 * ],
228 * excludeOfficeIps: true,
229 * });
230 * ```
231 *
232 * **Example:** Include-mode: only listed routes go through WARP
233 * ```typescript
234 * yield* Cloudflare.Devices.DeviceDefaultProfile("Default", {
235 * mode: "include",
236 * splitTunnelInclude: [
237 * { address: "10.42.0.0/16", description: "Prod VPC" },
238 * ],
239 * });
240 * ```
241 *
242 * ### Configuring fallback domains
243 * **Example:** Resolve a private suffix via an on-prem DNS server
244 * ```typescript
245 * yield* Cloudflare.Devices.DeviceDefaultProfile("Default", {
246 * fallbackDomains: [
247 * {
248 * suffix: "corp.example.com",
249 * dnsServer: ["10.0.0.53"],
250 * description: "Corp AD",
251 * },
252 * ],
253 * disableAutoFallback: true,
254 * });
255 * ```
256 *
257 * @resource
258 * @product Devices
259 * @category Cloudflare One (Zero Trust)
260 */
261export const DeviceDefaultProfile = Resource<DeviceDefaultProfile>(
262 "Cloudflare.Devices.DefaultProfile",
263);
264
265/**
266 * Live `Provider` for {@link DeviceDefaultProfile}. Wire into a Cloudflare
267 * provider Layer with `Provider.collection([DeviceDefaultProfile])` plus
268 * `DeviceDefaultProfileProvider()`.
269 */
270export const DeviceDefaultProfileProvider = () =>
271 Provider.succeed(DeviceDefaultProfile, {
272 nuke: { singleton: true },
273 stables: ["accountId"],
274 reconcile: Effect.fn(function* ({ news = {} }) {
275 const { accountId } = yield* yield* CloudflareEnvironment;
276 // 1. Observe — the default profile always exists.
277 let obs = yield* observe();
278
279 // 2. Sync — four independent idempotent diffs.
280
281 // (a) General profile patch.
282 const desiredMode = news.mode ?? inferMode(obs.profile);
283 const observedSm = obs.profile.serviceModeV2;
284 const desiredSm = news.serviceModeV2;
285 const patchBody: zeroTrust.PatchDevicePolicyDefaultRequest = {
286 accountId,
287 };
288 let needsPatch = false;
289 const setIf = <K extends keyof zeroTrust.PatchDevicePolicyDefaultRequest>(
290 key: K,
291 desired: zeroTrust.PatchDevicePolicyDefaultRequest[K],
292 observed: unknown,
293 ) => {
294 if (desired === undefined) return;
295 if (desired !== observed) {
296 patchBody[key] = desired;
297 needsPatch = true;
298 }
299 };
300 setIf(
301 "captivePortal",
302 news.captivePortal,
303 denull(obs.profile.captivePortal),
304 );
305 setIf("autoConnect", news.autoConnect, denull(obs.profile.autoConnect));
306 setIf(
307 "allowedToLeave",
308 news.allowedToLeave,
309 denull(obs.profile.allowedToLeave),
310 );
311 setIf(
312 "allowModeSwitch",
313 news.allowModeSwitch,
314 denull(obs.profile.allowModeSwitch),
315 );
316 setIf(
317 "allowUpdates",
318 news.allowUpdates,
319 denull(obs.profile.allowUpdates),
320 );
321 setIf(
322 "disableAutoFallback",
323 news.disableAutoFallback,
324 denull(obs.profile.disableAutoFallback),
325 );
326 setIf(
327 "excludeOfficeIps",
328 news.excludeOfficeIps,
329 denull(obs.profile.excludeOfficeIps),
330 );
331 setIf(
332 "switchLocked",
333 news.switchLocked,
334 denull(obs.profile.switchLocked),
335 );
336 // lanAllowMinutes / lanAllowSubnetSize are accepted by PATCH but
337 // not surfaced on GET, so we cannot diff them. Push them every
338 // time the user sets them — the API is idempotent.
339 if (news.lanAllowMinutes !== undefined) {
340 patchBody.lanAllowMinutes = news.lanAllowMinutes;
341 needsPatch = true;
342 }
343 if (news.lanAllowSubnetSize !== undefined) {
344 patchBody.lanAllowSubnetSize = news.lanAllowSubnetSize;
345 needsPatch = true;
346 }
347 setIf(
348 "registerInterfaceIpWithDns",
349 news.registerInterfaceIpWithDns,
350 denull(obs.profile.registerInterfaceIpWithDns),
351 );
352 setIf(
353 "sccmVpnBoundarySupport",
354 news.sccmVpnBoundarySupport,
355 denull(obs.profile.sccmVpnBoundarySupport),
356 );
357 setIf("supportUrl", news.supportUrl, denull(obs.profile.supportUrl));
358 setIf(
359 "tunnelProtocol",
360 news.tunnelProtocol,
361 denull(obs.profile.tunnelProtocol),
362 );
363
364 if (
365 desiredSm !== undefined &&
366 !sameJSON(
367 desiredSm,
368 observedSm
369 ? {
370 mode: denull(observedSm.mode),
371 port: denull(observedSm.port),
372 }
373 : undefined,
374 )
375 ) {
376 patchBody.serviceModeV2 = desiredSm;
377 needsPatch = true;
378 }
379
380 // `mode` is a derived view of which list is enforced; the CF
381 // PATCH endpoint surfaces it as a SIDE-EFFECT of which array
382 // (`include`/`exclude`) is non-empty. We let the include/exclude
383 // sync blocks below handle persistence.
384 void desiredMode;
385
386 if (needsPatch) {
387 yield* zeroTrust.patchDevicePolicyDefault(patchBody);
388 }
389
390 // (b) Include list.
391 if (news.splitTunnelInclude !== undefined) {
392 const desired = news.splitTunnelInclude;
393 if (!sameJSON(desired, obs.splitTunnelInclude)) {
394 yield* zeroTrust.putDevicePolicyDefaultInclude({
395 accountId,
396 body: desired.map(encodeSplit),
397 });
398 }
399 }
400
401 // (c) Exclude list.
402 if (news.splitTunnelExclude !== undefined) {
403 const desired = news.splitTunnelExclude;
404 if (!sameJSON(desired, obs.splitTunnelExclude)) {
405 yield* zeroTrust.putDevicePolicyDefaultExclude({
406 accountId,
407 body: desired.map(encodeSplit),
408 });
409 }
410 }
411
412 // (d) Fallback domains.
413 if (news.fallbackDomains !== undefined) {
414 const desired = news.fallbackDomains;
415 if (!sameJSON(desired, obs.fallbackDomains)) {
416 yield* zeroTrust.putDevicePolicyDefaultFallbackDomain({
417 accountId,
418 domains: desired.map(encodeFallback),
419 });
420 }
421 }
422
423 // 3. Re-observe so attrs reflect post-sync truth.
424 obs = yield* observe();
425 return buildAttrs(accountId, obs);
426 }),
427 delete: Effect.fn(function* () {
428 // The default device profile cannot be deleted. Drop our state
429 // and leave the cloud profile in place.
430 yield* Effect.logWarning(
431 "Cloudflare.Devices.DefaultProfile: delete is a no-op; the default device profile cannot be deleted.",
432 );
433 return Effect.void;
434 }),
435 read: Effect.fn(function* () {
436 const { accountId } = yield* yield* CloudflareEnvironment;
437 const obs = yield* observe();
438 return buildAttrs(accountId, obs);
439 }),
440 // Account-scoped singleton: there is exactly one default device profile
441 // per account and no enumeration API. Mirror `read` and return the single
442 // profile as a one-element array.
443 list: Effect.fn(function* () {
444 const { accountId } = yield* yield* CloudflareEnvironment;
445 const obs = yield* observe();
446 return [buildAttrs(accountId, obs)];
447 }),
448 });
449
450// Cloudflare returns `{result: null, success: true}` (NOT `[]`) when
451// a default-policy list endpoint is empty, and distilled's schema
452// rejects that as a transport error. Swallow into `undefined` so the
453// reconciler treats an empty list as "no entries" rather than failing.
454const listOrEmpty = <A, Err, Req>(
455 op: Effect.Effect<{ result?: readonly A[] | null }, Err, Req>,
456) =>
457 op.pipe(
458 Effect.catch(() =>
459 Effect.succeed({
460 result: [] as readonly A[],
461 }),
462 ),
463 );
464
465const observe = Effect.fn(function* () {
466 const { accountId } = yield* yield* CloudflareEnvironment;
467 const [profile, include, exclude, fallback] = yield* Effect.all(
468 [
469 zeroTrust.getDevicePolicyDefault({ accountId }),
470 listOrEmpty(zeroTrust.getDevicePolicyDefaultInclude({ accountId })),
471 listOrEmpty(zeroTrust.getDevicePolicyDefaultExclude({ accountId })),
472 listOrEmpty(
473 zeroTrust.getDevicePolicyDefaultFallbackDomain({ accountId }),
474 ),
475 ],
476 { concurrency: "unbounded" },
477 );
478 const inc = (include.result ?? []).map(normalizeSplit);
479 const exc = (exclude.result ?? []).map(normalizeSplit);
480 const fb = (fallback.result ?? []).map(normalizeFallback);
481 return {
482 profile,
483 splitTunnelInclude: inc,
484 splitTunnelExclude: exc,
485 fallbackDomains: fb,
486 };
487});
488
489const buildAttrs = (
490 accountId: string,
491 obs: Effect.Success<ReturnType<typeof observe>>,
492): DeviceDefaultProfile["Attributes"] => {
493 const { profile } = obs;
494 const sm = profile.serviceModeV2;
495 const serviceModeV2: DeviceDefaultProfile.ServiceModeV2 | undefined =
496 sm && sm.mode != null
497 ? {
498 mode: (sm.mode === "proxy" ? "proxy" : "warp") as "warp" | "proxy",
499 port: denull(sm.port),
500 }
501 : undefined;
502 return {
503 accountId,
504 mode: inferMode(profile),
505 splitTunnelInclude: obs.splitTunnelInclude,
506 splitTunnelExclude: obs.splitTunnelExclude,
507 fallbackDomains: obs.fallbackDomains,
508 captivePortal: denull(profile.captivePortal),
509 autoConnect: denull(profile.autoConnect),
510 allowedToLeave: denull(profile.allowedToLeave),
511 allowModeSwitch: denull(profile.allowModeSwitch),
512 allowUpdates: denull(profile.allowUpdates),
513 disableAutoFallback: denull(profile.disableAutoFallback),
514 excludeOfficeIps: denull(profile.excludeOfficeIps),
515 switchLocked: denull(profile.switchLocked),
516 serviceModeV2,
517 // lanAllow* are PATCH-only — GET does not echo them back.
518 lanAllowMinutes: undefined,
519 lanAllowSubnetSize: undefined,
520 registerInterfaceIpWithDns: denull(profile.registerInterfaceIpWithDns),
521 sccmVpnBoundarySupport: denull(profile.sccmVpnBoundarySupport),
522 supportUrl: denull(profile.supportUrl),
523 tunnelProtocol: denull(profile.tunnelProtocol),
524 };
525};
526
527/**
528 * Strip Cloudflare's `null` echoes to `undefined` so structural equality
529 * (`JSON.stringify`) works.
530 */
531const denull = <T>(v: T | null | undefined): T | undefined =>
532 v == null ? undefined : v;
533
534const normalizeSplit = (
535 entry:
536 | { address: string; description?: string | null }
537 | { host: string; description?: string | null },
538): DeviceDefaultProfile.SplitTunnelEntry => ({
539 address: "address" in entry ? entry.address : "",
540 host: "host" in entry ? entry.host : undefined,
541 description: denull(entry.description),
542});
543
544const normalizeFallback = (entry: {
545 suffix: string;
546 description?: string | null;
547 dnsServer?: string[] | null;
548}): DeviceDefaultProfile.FallbackDomain => ({
549 suffix: entry.suffix,
550 description: denull(entry.description),
551 dnsServer: denull(entry.dnsServer),
552});
553
554/**
555 * Encode a SplitTunnelEntry for the API's union shape (`address` OR `host`).
556 * Prefers `host` when set since the address would otherwise be empty.
557 */
558const encodeSplit = (
559 e: DeviceDefaultProfile.SplitTunnelEntry,
560):
561 | { address: string; description?: string }
562 | { host: string; description?: string } => {
563 if (e.host && !e.address) {
564 return e.description !== undefined
565 ? { host: e.host, description: e.description }
566 : { host: e.host };
567 }
568 return e.description !== undefined
569 ? { address: e.address, description: e.description }
570 : { address: e.address };
571};
572
573const encodeFallback = (
574 d: DeviceDefaultProfile.FallbackDomain,
575): { suffix: string; description?: string; dnsServer?: string[] } => {
576 const out: { suffix: string; description?: string; dnsServer?: string[] } = {
577 suffix: d.suffix,
578 };
579 if (d.description !== undefined) out.description = d.description;
580 if (d.dnsServer !== undefined) out.dnsServer = d.dnsServer;
581 return out;
582};
583
584/** Structural deep-equality via canonical JSON. */
585const sameJSON = (a: unknown, b: unknown): boolean =>
586 JSON.stringify(a) === JSON.stringify(b);
587
588const inferMode = (observed: {
589 include?: readonly unknown[] | null | undefined;
590}): "include" | "exclude" =>
591 observed.include && observed.include.length > 0 ? "include" : "exclude";