1import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import type { MagicTunnelBgp, MagicTunnelHealthCheck } from "./GreTunnel.ts";
13
14const TypeId = "Cloudflare.MagicTransit.IpsecTunnel" as const;
15type TypeId = typeof TypeId;
16
17export interface IpsecTunnelProps {
18 /**
19 * The name of the IPsec tunnel. Cannot share a name with other tunnels.
20 * Immutable in practice — changing it triggers a replacement.
21 */
22 name: string;
23 /**
24 * The IP address assigned to the Cloudflare side of the IPsec tunnel
25 * (a Cloudflare anycast IP allocated to the account).
26 */
27 cloudflareEndpoint: string;
28 /**
29 * The IP address assigned to the customer side of the IPsec tunnel. Not
30 * required, but must be set for proactive traceroutes to work.
31 */
32 customerEndpoint?: string;
33 /**
34 * A 31-bit prefix (/31 in CIDR notation) from RFC1918 private space; one
35 * host for each side of the tunnel.
36 */
37 interfaceAddress: string;
38 /**
39 * A /127 IPv6 prefix from within the account's `virtual_subnet6` space.
40 */
41 interfaceAddress6?: string;
42 /**
43 * An optional description of the IPsec tunnel.
44 */
45 description?: string;
46 /**
47 * Pre-shared key for the tunnel. Write-only — Cloudflare never returns
48 * it, so the value is carried in state. When omitted, Cloudflare leaves
49 * the tunnel without a PSK until one is generated via the dashboard/API.
50 */
51 psk?: Redacted.Redacted<string>;
52 /**
53 * If `true`, IPsec replay protection is supported in the
54 * Cloudflare-to-customer direction.
55 * @default false
56 */
57 replayProtection?: boolean;
58 /**
59 * Tunnel health-check configuration.
60 */
61 healthCheck?: MagicTunnelHealthCheck;
62 /**
63 * BGP configuration for the tunnel.
64 */
65 bgp?: MagicTunnelBgp;
66 /**
67 * Custom remote identities for IKE negotiation.
68 */
69 customRemoteIdentities?: {
70 /** Identifier of the FQDN remote identity. */
71 fqdnId?: string;
72 };
73 /**
74 * True if automatic stateful return routing should be enabled. Requires
75 * the `coupler_integration` account flag.
76 * @default false
77 */
78 automaticReturnRouting?: boolean;
79}
80
81export interface IpsecTunnelAttributes {
82 /** Cloudflare-assigned identifier of the IPsec tunnel. */
83 tunnelId: string;
84 /** The Cloudflare account the tunnel belongs to. */
85 accountId: string;
86 /** The name of the tunnel. */
87 name: string;
88 /** The IP address on the Cloudflare side of the tunnel. */
89 cloudflareEndpoint: string;
90 /** The IP address on the customer side of the tunnel, if set. */
91 customerEndpoint: string | undefined;
92 /** The /31 interface address of the tunnel. */
93 interfaceAddress: string;
94 /** The /127 IPv6 interface address, if configured. */
95 interfaceAddress6: string | undefined;
96 /** The tunnel description, if set. */
97 description: string | undefined;
98 /**
99 * The pre-shared key as configured. Write-only on Cloudflare's side —
100 * this is the value from props, carried in state, never read back.
101 */
102 psk: Redacted.Redacted<string> | undefined;
103 /** Whether the tunnel allows a null cipher (`ENCR_NULL`) in Phase 2. */
104 allowNullCipher: boolean | undefined;
105 /** Whether replay protection is enabled. */
106 replayProtection: boolean | undefined;
107 /** ISO8601 creation timestamp. */
108 createdOn: string | undefined;
109 /** ISO8601 last-modified timestamp. */
110 modifiedOn: string | undefined;
111}
112
113export type IpsecTunnel = Resource<
114 TypeId,
115 IpsecTunnelProps,
116 IpsecTunnelAttributes,
117 never,
118 Providers
119>;
120
121/**
122 * A Magic Transit / Magic WAN IPsec tunnel between Cloudflare and a
123 * customer device.
124 *
125 * Requires a Magic Transit or Magic WAN subscription on the account —
126 * accounts that are not onboarded receive a typed
127 * `MagicTransitNotOnboarded` error (Cloudflare code 1012).
128 *
129 * The tunnel `name` is unique per account and immutable in practice —
130 * changing it triggers a replacement. The `psk` is write-only: Cloudflare
131 * never returns it, so the configured value is carried in state.
132 * ### Creating an IPsec tunnel
133 * **Example:** Basic tunnel with a provided PSK
134 * ```typescript
135 * const tunnel = yield* Cloudflare.MagicTransit.IpsecTunnel("branch", {
136 * name: "branch-ipsec-1",
137 * cloudflareEndpoint: "203.0.113.1",
138 * customerEndpoint: "198.51.100.1",
139 * interfaceAddress: "10.213.0.10/31",
140 * psk: yield* Config.Redacted("IPSEC_PSK"),
141 * });
142 * ```
143 *
144 * **Example:** Tunnel with replay protection and health checks
145 * ```typescript
146 * const tunnel = yield* Cloudflare.MagicTransit.IpsecTunnel("branch", {
147 * name: "branch-ipsec-1",
148 * cloudflareEndpoint: "203.0.113.1",
149 * interfaceAddress: "10.213.0.10/31",
150 * replayProtection: true,
151 * healthCheck: { enabled: true, rate: "mid" },
152 * });
153 * ```
154 *
155 * @see https://developers.cloudflare.com/magic-wan/reference/tunnels/
156 *
157 * @resource
158 * @product Magic Transit
159 * @category Network
160 */
161export const IpsecTunnel = Resource<IpsecTunnel>(TypeId);
162
163/**
164 * Returns true if the given value is an IpsecTunnel resource.
165 */
166export const isIpsecTunnel = (value: unknown): value is IpsecTunnel =>
167 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
168
169export const IpsecTunnelProvider = () =>
170 Provider.succeed(IpsecTunnel, {
171 stables: ["tunnelId", "accountId", "createdOn"],
172
173 diff: Effect.fn(function* ({ olds, news }) {
174 if (!isResolved(news)) return undefined;
175 if (olds === undefined) return undefined;
176 // The tunnel name is unique routing identity; renames are rejected.
177 if (olds.name !== news.name) return { action: "replace" } as const;
178 return undefined;
179 }),
180
181 read: Effect.fn(function* ({ output, olds }) {
182 const { accountId } = yield* yield* CloudflareEnvironment;
183 const acct = output?.accountId ?? accountId;
184
185 if (output?.tunnelId) {
186 const observed = yield* getTunnel(acct, output.tunnelId);
187 // The PSK is write-only — carry the persisted value forward.
188 if (observed) return toAttributes(observed, acct, output.psk);
189 }
190 // Cold read — tunnel names are unique per account. Tunnels carry no
191 // ownership markers; report as Unowned so takeover is gated behind
192 // the adopt policy.
193 const name = output?.name ?? olds?.name;
194 if (name) {
195 const observed = yield* findByName(acct, name);
196 if (observed) {
197 return Unowned(toAttributes(observed, acct, output?.psk));
198 }
199 }
200 return undefined;
201 }),
202
203 reconcile: Effect.fn(function* ({ news, olds, output }) {
204 const { accountId } = yield* yield* CloudflareEnvironment;
205
206 // Observe — the id on `output` is a hint; fall through to the
207 // unique-name lookup when it is gone.
208 let observed = output?.tunnelId
209 ? yield* getTunnel(accountId, output.tunnelId)
210 : undefined;
211 if (!observed) {
212 observed = yield* findByName(accountId, news.name);
213 }
214
215 const psk = news.psk ? Redacted.value(news.psk) : undefined;
216
217 // Ensure — create when missing.
218 if (!observed) {
219 const created = yield* magicTransit.createIpsecTunnel({
220 accountId,
221 xMagicNewHcTarget: true,
222 name: news.name,
223 cloudflareEndpoint: news.cloudflareEndpoint,
224 customerEndpoint: news.customerEndpoint,
225 interfaceAddress: news.interfaceAddress,
226 interfaceAddress6: news.interfaceAddress6,
227 description: news.description,
228 psk,
229 replayProtection: news.replayProtection,
230 automaticReturnRouting: news.automaticReturnRouting,
231 customRemoteIdentities: news.customRemoteIdentities,
232 bgp: toBgpRequest(news.bgp),
233 healthCheck: toHealthCheckRequest(news.healthCheck),
234 });
235 return toAttributes(created, accountId, news.psk);
236 }
237
238 // Sync — diff observed cloud state against desired; the update API
239 // is a full PUT, so send everything, but skip the call on a no-op.
240 // The PSK cannot be observed: it is dirty when the desired value
241 // differs from the last-applied props.
242 const oldPsk = olds?.psk ? Redacted.value(olds.psk) : undefined;
243 const pskDirty = psk !== undefined && psk !== oldPsk;
244 if (dirty(observed, news) || pskDirty) {
245 const updated = yield* magicTransit.updateIpsecTunnel({
246 accountId,
247 ipsecTunnelId: observed.id,
248 xMagicNewHcTarget: true,
249 name: news.name,
250 cloudflareEndpoint: news.cloudflareEndpoint,
251 customerEndpoint: news.customerEndpoint,
252 interfaceAddress: news.interfaceAddress,
253 interfaceAddress6: news.interfaceAddress6,
254 description: news.description,
255 psk: pskDirty ? psk : undefined,
256 replayProtection: news.replayProtection,
257 automaticReturnRouting: news.automaticReturnRouting,
258 customRemoteIdentities: news.customRemoteIdentities,
259 bgp: toBgpRequest(news.bgp),
260 healthCheck: toHealthCheckRequest(news.healthCheck),
261 });
262 observed =
263 updated.modifiedIpsecTunnel ??
264 (yield* getTunnel(accountId, observed.id)) ??
265 observed;
266 }
267
268 return toAttributes(observed, accountId, news.psk);
269 }),
270
271 delete: Effect.fn(function* ({ output }) {
272 yield* magicTransit
273 .deleteIpsecTunnel({
274 accountId: output.accountId,
275 ipsecTunnelId: output.tunnelId,
276 xMagicNewHcTarget: true,
277 })
278 .pipe(Effect.catchTag("IpsecTunnelNotFound", () => Effect.void));
279 }),
280
281 // Account-scoped collection. The list API returns the full tunnel set in
282 // a single response (non-paginated). The PSK is write-only and never
283 // returned, so it is `undefined` here — matching `read`'s cold-read shape.
284 list: Effect.fn(function* () {
285 const { accountId } = yield* yield* CloudflareEnvironment;
286 return yield* magicTransit
287 .listIpsecTunnels({ accountId, xMagicNewHcTarget: true })
288 .pipe(
289 Effect.map((r) =>
290 (r.ipsecTunnels ?? []).map((tunnel) =>
291 toAttributes(tunnel, accountId, undefined),
292 ),
293 ),
294 // Accounts that aren't onboarded onto Magic Transit (code 1012)
295 // or lack the entitlement can't enumerate tunnels — treat as none.
296 Effect.catchTag(
297 ["MagicTransitNotOnboarded", "Forbidden"],
298 (): Effect.Effect<IpsecTunnelAttributes[]> => Effect.succeed([]),
299 ),
300 );
301 }),
302 });
303
304interface ObservedIpsecTunnel {
305 id: string;
306 name: string;
307 cloudflareEndpoint: string;
308 interfaceAddress: string;
309 customerEndpoint?: string | null;
310 interfaceAddress6?: string | null;
311 description?: string | null;
312 allowNullCipher?: boolean | null;
313 replayProtection?: boolean | null;
314 healthCheck?: {
315 direction?: string | null;
316 enabled?: boolean | null;
317 rate?: string | null;
318 target?:
319 | { effective?: string | null; saved?: string | null }
320 | string
321 | null;
322 type?: string | null;
323 } | null;
324 createdOn?: string | null;
325 modifiedOn?: string | null;
326}
327
328/**
329 * Read a tunnel by id, mapping "gone" (`IpsecTunnelNotFound`, Cloudflare
330 * error code 1032) to `undefined`.
331 */
332const getTunnel = (accountId: string, ipsecTunnelId: string) =>
333 magicTransit
334 .getIpsecTunnel({ accountId, ipsecTunnelId, xMagicNewHcTarget: true })
335 .pipe(
336 Effect.map(
337 (r): ObservedIpsecTunnel | undefined => r.ipsecTunnel ?? undefined,
338 ),
339 Effect.catchTag("IpsecTunnelNotFound", () => Effect.succeed(undefined)),
340 );
341
342/**
343 * Find a tunnel by exact name. Names are unique per account, so at most
344 * one tunnel can match.
345 */
346const findByName = (accountId: string, name: string) =>
347 magicTransit
348 .listIpsecTunnels({ accountId, xMagicNewHcTarget: true })
349 .pipe(
350 Effect.map((r): ObservedIpsecTunnel | undefined =>
351 (r.ipsecTunnels ?? []).find((t) => t.name === name),
352 ),
353 );
354
355const toBgpRequest = (bgp: MagicTunnelBgp | undefined) =>
356 bgp
357 ? {
358 customerAsn: bgp.customerAsn,
359 extraPrefixes: bgp.extraPrefixes,
360 md5Key: bgp.md5Key ? Redacted.value(bgp.md5Key) : undefined,
361 }
362 : undefined;
363
364const toHealthCheckRequest = (hc: MagicTunnelHealthCheck | undefined) =>
365 hc
366 ? {
367 enabled: hc.enabled,
368 direction: hc.direction,
369 rate: hc.rate,
370 type: hc.type,
371 target: hc.target ? { saved: hc.target } : undefined,
372 }
373 : undefined;
374
375const observedHealthTarget = (
376 healthCheck: ObservedIpsecTunnel["healthCheck"],
377): string | undefined => {
378 const target = healthCheck?.target;
379 if (typeof target === "string") return target;
380 return target?.saved ?? undefined;
381};
382
383const dirty = (
384 observed: ObservedIpsecTunnel,
385 news: IpsecTunnelProps,
386): boolean =>
387 observed.cloudflareEndpoint !== news.cloudflareEndpoint ||
388 observed.interfaceAddress !== news.interfaceAddress ||
389 (news.customerEndpoint !== undefined &&
390 (observed.customerEndpoint ?? undefined) !== news.customerEndpoint) ||
391 (news.interfaceAddress6 !== undefined &&
392 (observed.interfaceAddress6 ?? undefined) !== news.interfaceAddress6) ||
393 (news.description !== undefined &&
394 (observed.description ?? undefined) !== news.description) ||
395 (news.replayProtection !== undefined &&
396 (observed.replayProtection ?? false) !== news.replayProtection) ||
397 healthCheckDirty(observed.healthCheck, news.healthCheck);
398
399const healthCheckDirty = (
400 observed: ObservedIpsecTunnel["healthCheck"],
401 desired: MagicTunnelHealthCheck | undefined,
402): boolean => {
403 if (desired === undefined) return false;
404 const hc = observed ?? {};
405 return (
406 (desired.enabled !== undefined &&
407 (hc.enabled ?? undefined) !== desired.enabled) ||
408 (desired.direction !== undefined &&
409 (hc.direction ?? undefined) !== desired.direction) ||
410 (desired.rate !== undefined && (hc.rate ?? undefined) !== desired.rate) ||
411 (desired.type !== undefined && (hc.type ?? undefined) !== desired.type) ||
412 (desired.target !== undefined &&
413 observedHealthTarget(hc) !== desired.target)
414 );
415};
416
417const toAttributes = (
418 tunnel: ObservedIpsecTunnel,
419 accountId: string,
420 psk: Redacted.Redacted<string> | undefined,
421): IpsecTunnelAttributes => ({
422 tunnelId: tunnel.id,
423 accountId,
424 name: tunnel.name,
425 cloudflareEndpoint: tunnel.cloudflareEndpoint,
426 customerEndpoint: tunnel.customerEndpoint ?? undefined,
427 interfaceAddress: tunnel.interfaceAddress,
428 interfaceAddress6: tunnel.interfaceAddress6 ?? undefined,
429 description: tunnel.description ?? undefined,
430 psk,
431 allowNullCipher: tunnel.allowNullCipher ?? undefined,
432 replayProtection: tunnel.replayProtection ?? undefined,
433 createdOn: tunnel.createdOn ?? undefined,
434 modifiedOn: tunnel.modifiedOn ?? undefined,
435});