1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Devices.ManagedNetwork" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * Detection configuration for a managed network: the WARP client opens a
18 * TLS connection to `tlsSockaddr` and (optionally) verifies the
19 * certificate's SHA-256 fingerprint to decide whether the device is on
20 * the network.
21 */
22export interface DeviceManagedNetworkConfig {
23 /**
24 * A network address of the form `host:port` that the WARP client will
25 * probe over TLS to detect the network (e.g. `"192.0.2.1:443"`).
26 */
27 tlsSockaddr: string;
28 /**
29 * The SHA-256 fingerprint (64 hexadecimal characters) of the TLS
30 * certificate served at {@link tlsSockaddr}. When set, the client only
31 * considers the network detected if the certificate matches.
32 */
33 sha256?: string;
34}
35
36export interface DeviceManagedNetworkProps {
37 /**
38 * Name of the managed network. Must be unique within the account. If
39 * omitted, a unique name is generated from the app, stage, and logical
40 * ID.
41 * @default ${app}-${stage}-${id}
42 */
43 name?: string;
44 /**
45 * TLS detection endpoint for the network. Mutable — updated in place.
46 */
47 config: DeviceManagedNetworkConfig;
48}
49
50export type DeviceManagedNetworkAttributes = {
51 /** API UUID of the managed network. */
52 networkId: string;
53 /** Account that owns the managed network. */
54 accountId: string;
55 /** Observed network name. */
56 name: string;
57 /** The type of managed network — always `tls`. */
58 type: "tls";
59 /** Observed TLS detection configuration. */
60 config: DeviceManagedNetworkConfig;
61};
62
63export type DeviceManagedNetwork = Resource<
64 TypeId,
65 DeviceManagedNetworkProps,
66 DeviceManagedNetworkAttributes,
67 never,
68 Providers
69>;
70
71/**
72 * A Cloudflare Zero Trust **device managed network** — a TLS endpoint the
73 * WARP client probes to detect whether the device is on a known network.
74 * Device profiles can then `match` on `network` to apply different WARP
75 * settings on trusted networks.
76 *
77 * Name and config are mutable in place (PUT). `tls` is the only network
78 * type Cloudflare supports.
79 * ### Creating a managed network
80 * **Example:** Detect the office network by TLS fingerprint
81 * ```typescript
82 * const network = yield* Cloudflare.Devices.DeviceManagedNetwork("Office", {
83 * config: {
84 * tlsSockaddr: "192.0.2.1:443",
85 * sha256:
86 * "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c",
87 * },
88 * });
89 * ```
90 *
91 * **Example:** Use the network in a custom device profile
92 * ```typescript
93 * yield* Cloudflare.Devices.DeviceCustomProfile("OnPrem", {
94 * match: `network == "${network.name}"`,
95 * precedence: 100,
96 * serviceModeV2: { mode: "proxy", port: 3000 },
97 * });
98 * ```
99 *
100 * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/configure-warp/managed-networks/
101 *
102 * @resource
103 * @product Devices
104 * @category Cloudflare One (Zero Trust)
105 */
106export const DeviceManagedNetwork = Resource<DeviceManagedNetwork>(TypeId);
107
108/**
109 * Returns true if the given value is a DeviceManagedNetwork resource.
110 */
111export const isDeviceManagedNetwork = (
112 value: unknown,
113): value is DeviceManagedNetwork =>
114 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
115
116export const DeviceManagedNetworkProvider = () =>
117 Provider.succeed(DeviceManagedNetwork, {
118 stables: ["networkId", "accountId", "type"],
119
120 // Account collection: managed networks are account-scoped and the
121 // distilled list op paginates (items: "result"). Enumerate every page
122 // and hydrate into the exact `read` Attributes shape via `toAttributes`.
123 list: Effect.fn(function* () {
124 const { accountId } = yield* yield* CloudflareEnvironment;
125 return yield* zeroTrust.listDeviceNetworks.pages({ accountId }).pipe(
126 Stream.runCollect,
127 Effect.map((chunk) =>
128 Array.from(chunk).flatMap((page) =>
129 (page.result ?? [])
130 .filter((n) => n.networkId != null)
131 .map((n) => toAttributes(n, accountId)),
132 ),
133 ),
134 );
135 }),
136
137 read: Effect.fn(function* ({ id, output, olds }) {
138 const { accountId } = yield* yield* CloudflareEnvironment;
139 const acct = output?.accountId ?? accountId;
140
141 // Owned path: refresh by our persisted network id.
142 if (output?.networkId) {
143 const observed = yield* observeNetwork(acct, output.networkId);
144 return observed ? toAttributes(observed, acct) : undefined;
145 }
146
147 // Cold lookup: names are unique per account, so an exact match
148 // identifies the network — but it carries no ownership markers, so
149 // brand it `Unowned` and let the engine gate takeover behind the
150 // adopt policy.
151 const name = yield* createNetworkName(id, olds?.name);
152 const match = yield* findByName(acct, name);
153 if (match) return Unowned(toAttributes(match, acct));
154 return undefined;
155 }),
156
157 reconcile: Effect.fn(function* ({ id, news, output }) {
158 const { accountId } = yield* yield* CloudflareEnvironment;
159 const name = yield* createNetworkName(id, news.name);
160
161 // 1. Observe — the network id cached on `output` is a hint, not a
162 // guarantee. Names are unique per account, so also scan by name
163 // to converge on a network created by a crashed prior run.
164 let observed = output?.networkId
165 ? yield* observeNetwork(accountId, output.networkId)
166 : undefined;
167 if (!observed) {
168 observed = yield* findByName(accountId, name);
169 }
170
171 // 2. Ensure — create when missing.
172 if (!observed) {
173 const created = yield* zeroTrust.createDeviceNetwork({
174 accountId,
175 name,
176 type: "tls",
177 config: encodeConfig(news.config),
178 });
179 return toAttributes(created, accountId);
180 }
181
182 // 3. Sync — PUT name/config when the observed state differs; skip
183 // the call entirely on a no-op.
184 const dirty =
185 (observed.name ?? "") !== name ||
186 !sameConfig(observed.config, news.config);
187 if (!dirty) {
188 return toAttributes(observed, accountId);
189 }
190 const updated = yield* zeroTrust.updateDeviceNetwork({
191 accountId,
192 networkId: observed.networkId!,
193 name,
194 type: "tls",
195 config: encodeConfig(news.config),
196 });
197 return toAttributes(updated, accountId);
198 }),
199
200 delete: Effect.fn(function* ({ output }) {
201 // Cloudflare's delete is already idempotent (200 on a missing
202 // network); the typed catch covers the documented error path too.
203 yield* zeroTrust
204 .deleteDeviceNetwork({
205 accountId: output.accountId,
206 networkId: output.networkId,
207 })
208 .pipe(Effect.catchTag("DeviceNetworkNotFound", () => Effect.void));
209 }),
210 });
211
212/**
213 * Structural shape shared by get/list/create/update responses.
214 */
215type ObservedNetwork = {
216 config?: { tlsSockaddr: string; sha256?: string | null } | null;
217 name?: string | null;
218 networkId?: string | null;
219 type?: "tls" | (string & {}) | null;
220};
221
222/**
223 * Read a managed network by id, mapping "gone" (`DeviceNetworkNotFound`,
224 * Cloudflare error code 2053) to `undefined`.
225 */
226const observeNetwork = (accountId: string, networkId: string) =>
227 zeroTrust
228 .getDeviceNetwork({ accountId, networkId })
229 .pipe(
230 Effect.catchTag("DeviceNetworkNotFound", () => Effect.succeed(undefined)),
231 );
232
233/**
234 * Find a managed network by exact name. Names are unique per account.
235 */
236const findByName = (accountId: string, name: string) =>
237 zeroTrust
238 .listDeviceNetworks({ accountId })
239 .pipe(
240 Effect.map((list) =>
241 list.result.find((n) => n.name === name && n.networkId != null),
242 ),
243 );
244
245const createNetworkName = (id: string, name: string | undefined) =>
246 Effect.gen(function* () {
247 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
248 });
249
250const encodeConfig = (
251 config: DeviceManagedNetworkConfig,
252): { tlsSockaddr: string; sha256?: string } => {
253 const out: { tlsSockaddr: string; sha256?: string } = {
254 tlsSockaddr: config.tlsSockaddr,
255 };
256 if (config.sha256 !== undefined) out.sha256 = config.sha256;
257 return out;
258};
259
260const sameConfig = (
261 observed: ObservedNetwork["config"],
262 desired: DeviceManagedNetworkConfig,
263): boolean =>
264 observed != null &&
265 observed.tlsSockaddr === desired.tlsSockaddr &&
266 (observed.sha256 ?? undefined) === desired.sha256;
267
268const toAttributes = (
269 network: ObservedNetwork,
270 accountId: string,
271): DeviceManagedNetworkAttributes => ({
272 networkId: network.networkId ?? "",
273 accountId,
274 name: network.name ?? "",
275 type: "tls",
276 config: {
277 tlsSockaddr: network.config?.tlsSockaddr ?? "",
278 sha256: network.config?.sha256 ?? undefined,
279 },
280});