| 1 | import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | |
| 4 | import * as Provider from "../../Provider.ts"; |
| 5 | import { Resource } from "../../Resource.ts"; |
| 6 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 7 | import 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 | */ |
| 18 | export 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 | |
| 121 | export 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 | |
| 156 | export 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 | */ |
| 261 | export 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 | */ |
| 270 | export 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. |
| 454 | const 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 | |
| 465 | const 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 | |
| 489 | const 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 | */ |
| 531 | const denull = <T>(v: T | null | undefined): T | undefined => |
| 532 | v == null ? undefined : v; |
| 533 | |
| 534 | const 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 | |
| 544 | const 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 | */ |
| 558 | const 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 | |
| 573 | const 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. */ |
| 585 | const sameJSON = (a: unknown, b: unknown): boolean => |
| 586 | JSON.stringify(a) === JSON.stringify(b); |
| 587 | |
| 588 | const inferMode = (observed: { |
| 589 | include?: readonly unknown[] | null | undefined; |
| 590 | }): "include" | "exclude" => |
| 591 | observed.include && observed.include.length > 0 ? "include" : "exclude"; |