1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.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.Access.InfrastructureTarget" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * IPv4/IPv6 address details for an infrastructure target. At least one
18 * of `ipv4` / `ipv6` must be provided.
19 */
20export interface InfrastructureTargetIp {
21 /** IPv4 address of the target, optionally scoped to a virtual network. */
22 ipv4?: {
23 /** The IPv4 address (e.g. `10.0.0.5`). */
24 ipAddr: string;
25 /**
26 * Virtual network the address lives in. Defaults to the account's
27 * default virtual network.
28 */
29 virtualNetworkId?: string;
30 };
31 /** IPv6 address of the target, optionally scoped to a virtual network. */
32 ipv6?: {
33 /** The IPv6 address. */
34 ipAddr: string;
35 /**
36 * Virtual network the address lives in. Defaults to the account's
37 * default virtual network.
38 */
39 virtualNetworkId?: string;
40 };
41}
42
43export interface InfrastructureTargetProps {
44 /**
45 * Hostname identifying the target. Non-unique, case-insensitive, max
46 * 255 characters; supports dashes and periods, no spaces. Mutable —
47 * updated in place via PUT.
48 */
49 hostname: string;
50 /**
51 * The IPv4/IPv6 address that identifies where to reach the target. At
52 * least one of `ipv4` / `ipv6` is required. Mutable — updated in
53 * place via PUT.
54 */
55 ip: InfrastructureTargetIp;
56}
57
58export interface InfrastructureTargetAttributes {
59 /** UUID of the infrastructure target, assigned by Cloudflare. */
60 targetId: string;
61 /** Cloudflare account that owns the target. */
62 accountId: string;
63 /** Hostname identifying the target. */
64 hostname: string;
65 /** Resolved IPv4/IPv6 addresses of the target. */
66 ip: {
67 ipv4?: { ipAddr?: string; virtualNetworkId?: string };
68 ipv6?: { ipAddr?: string; virtualNetworkId?: string };
69 };
70 /** RFC 3339 timestamp of when the target was created. */
71 createdAt: string;
72 /** RFC 3339 timestamp of when the target was last modified. */
73 modifiedAt: string;
74}
75
76export type InfrastructureTarget = Resource<
77 TypeId,
78 InfrastructureTargetProps,
79 InfrastructureTargetAttributes,
80 never,
81 Providers
82>;
83
84/**
85 * A Cloudflare Access Infrastructure Target — a server (hostname +
86 * IPv4/IPv6 address) protected by Access for Infrastructure (SSH).
87 *
88 * Targets are referenced by infrastructure Access applications, which
89 * attach SSH access policies to them. Hostname and IP are both mutable
90 * in place; the target's identity is its Cloudflare-assigned UUID.
91 * ### Creating a Target
92 * **Example:** Basic IPv4 target
93 * ```typescript
94 * const target = yield* Cloudflare.Access.InfrastructureTarget("Bastion", {
95 * hostname: "bastion.internal",
96 * ip: { ipv4: { ipAddr: "10.0.0.5" } },
97 * });
98 * ```
99 *
100 * **Example:** Target scoped to a virtual network
101 * ```typescript
102 * const vnet = yield* Cloudflare.Tunnel.VirtualNetwork("Staging", {});
103 * const target = yield* Cloudflare.Access.InfrastructureTarget("DbHost", {
104 * hostname: "db.staging.internal",
105 * ip: {
106 * ipv4: {
107 * ipAddr: "10.4.0.10",
108 * virtualNetworkId: vnet.virtualNetworkId,
109 * },
110 * },
111 * });
112 * ```
113 *
114 * ### Updating
115 * **Example:** Re-point the target at a new address
116 * ```typescript
117 * // Hostname and IP update in place — same targetId, no replacement.
118 * const target = yield* Cloudflare.Access.InfrastructureTarget("Bastion", {
119 * hostname: "bastion.internal",
120 * ip: { ipv4: { ipAddr: "10.0.0.6" } },
121 * });
122 * ```
123 *
124 * @see https://developers.cloudflare.com/cloudflare-one/applications/non-http/infrastructure-apps/
125 *
126 * @resource
127 * @product Access
128 * @category Cloudflare One (Zero Trust)
129 */
130export const InfrastructureTarget = Resource<InfrastructureTarget>(TypeId);
131
132/**
133 * Returns true if the given value is an InfrastructureTarget resource.
134 */
135export const isInfrastructureTarget = (
136 value: unknown,
137): value is InfrastructureTarget =>
138 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
139
140export const InfrastructureTargetProvider = () =>
141 Provider.succeed(InfrastructureTarget, {
142 stables: ["targetId", "accountId", "createdAt"],
143
144 list: Effect.fn(function* () {
145 const { accountId } = yield* yield* CloudflareEnvironment;
146 return yield* zeroTrust.listAccessInfrastructureTargets
147 .pages({ accountId })
148 .pipe(
149 Stream.runCollect,
150 Effect.map((chunk) =>
151 Array.from(chunk).flatMap((page) =>
152 (page.result ?? []).map((t) => toAttributes(t, accountId)),
153 ),
154 ),
155 );
156 }),
157
158 diff: Effect.fn(function* ({ output }) {
159 const { accountId } = yield* yield* CloudflareEnvironment;
160 if ((output?.accountId ?? accountId) !== accountId) {
161 return { action: "replace" } as const;
162 }
163 // hostname / ip both converge via PUT.
164 return undefined;
165 }),
166
167 read: Effect.fn(function* ({ output, olds }) {
168 const { accountId } = yield* yield* CloudflareEnvironment;
169 const acct = output?.accountId ?? accountId;
170
171 // Owned path — refresh by the cached id.
172 if (output?.targetId) {
173 const observed = yield* getTarget(acct, output.targetId);
174 if (observed) return toAttributes(observed, acct);
175 }
176
177 // Cold read — hostnames are non-unique, so only an exact
178 // hostname + address match identifies the target. Targets carry no
179 // ownership markers, so gate adoption behind Unowned.
180 const hostname = olds?.hostname ?? output?.hostname;
181 if (!hostname) return undefined;
182 // `olds.ip` may be `undefined` when a `creating` row was persisted
183 // before upstream Outputs resolved — fall back to the output hint.
184 const desiredIp =
185 olds?.ip !== undefined ? resolvedIp(olds.ip) : output?.ip;
186 const match = yield* findByIdentity(acct, hostname, desiredIp);
187 if (match) return Unowned(toAttributes(match, acct));
188 return undefined;
189 }),
190
191 reconcile: Effect.fn(function* ({ news, output }) {
192 const { accountId } = yield* yield* CloudflareEnvironment;
193 const desired = { hostname: news.hostname, ip: resolvedIp(news.ip) };
194
195 // 1. Observe — the cached id is a hint, not a guarantee.
196 let observed = output?.targetId
197 ? yield* getTarget(accountId, output.targetId)
198 : undefined;
199 if (!observed) {
200 observed = yield* findByIdentity(
201 accountId,
202 desired.hostname,
203 desired.ip,
204 );
205 }
206
207 // 2. Ensure — create when missing.
208 if (!observed) {
209 observed = yield* zeroTrust.createAccessInfrastructureTarget({
210 accountId,
211 hostname: desired.hostname,
212 ip: desired.ip,
213 });
214 }
215
216 // 3. Sync — PUT the full desired state only when the observed
217 // hostname or addresses differ; skip the call on a no-op.
218 const dirty =
219 observed.hostname !== desired.hostname ||
220 !ipEquals(observedIp(observed), desired.ip);
221 if (dirty) {
222 observed = yield* zeroTrust.updateAccessInfrastructureTarget({
223 accountId,
224 targetId: observed.id,
225 hostname: desired.hostname,
226 ip: desired.ip,
227 });
228 }
229
230 return toAttributes(observed, accountId);
231 }),
232
233 delete: Effect.fn(function* ({ output }) {
234 // A missing target (404) means we're done.
235 yield* zeroTrust
236 .deleteAccessInfrastructureTarget({
237 accountId: output.accountId,
238 targetId: output.targetId,
239 })
240 .pipe(Effect.catchTag("TargetNotFound", () => Effect.void));
241 }),
242 });
243
244type ObservedTarget = zeroTrust.GetAccessInfrastructureTargetResponse;
245
246type ResolvedIp = {
247 ipv4?: { ipAddr?: string; virtualNetworkId?: string };
248 ipv6?: { ipAddr?: string; virtualNetworkId?: string };
249};
250
251/**
252 * Read a target by id, mapping "gone" (404) to `undefined`.
253 */
254const getTarget = (accountId: string, targetId: string) =>
255 zeroTrust
256 .getAccessInfrastructureTarget({ accountId, targetId })
257 .pipe(Effect.catchTag("TargetNotFound", () => Effect.succeed(undefined)));
258
259/**
260 * Find a target by exact hostname + address match. Hostnames are
261 * non-unique, so the addresses disambiguate.
262 */
263const findByIdentity = (
264 accountId: string,
265 hostname: string,
266 ip: ResolvedIp | undefined,
267) =>
268 zeroTrust.listAccessInfrastructureTargets.items({ accountId, hostname }).pipe(
269 Stream.filter(
270 (t) =>
271 t.hostname === hostname &&
272 (ip === undefined || ipEquals(observedIp(t), ip)),
273 ),
274 Stream.runHead,
275 Effect.map(Option.getOrUndefined),
276 );
277
278/**
279 * Narrow the props' `ip` (whose virtualNetworkIds are `Input`s already
280 * resolved to strings by Plan) to the concrete wire shape.
281 */
282const resolvedIp = (ip: InfrastructureTargetIp): ResolvedIp => ({
283 ...(ip.ipv4
284 ? {
285 ipv4: {
286 ipAddr: ip.ipv4.ipAddr,
287 ...(typeof ip.ipv4.virtualNetworkId === "string"
288 ? { virtualNetworkId: ip.ipv4.virtualNetworkId }
289 : {}),
290 },
291 }
292 : {}),
293 ...(ip.ipv6
294 ? {
295 ipv6: {
296 ipAddr: ip.ipv6.ipAddr,
297 ...(typeof ip.ipv6.virtualNetworkId === "string"
298 ? { virtualNetworkId: ip.ipv6.virtualNetworkId }
299 : {}),
300 },
301 }
302 : {}),
303});
304
305const observedIp = (t: {
306 ip: {
307 ipv4?: { ipAddr?: string | null; virtualNetworkId?: string | null } | null;
308 ipv6?: { ipAddr?: string | null; virtualNetworkId?: string | null } | null;
309 };
310}): ResolvedIp => ({
311 ...(t.ip.ipv4
312 ? {
313 ipv4: {
314 ipAddr: t.ip.ipv4.ipAddr ?? undefined,
315 ...(t.ip.ipv4.virtualNetworkId
316 ? { virtualNetworkId: t.ip.ipv4.virtualNetworkId }
317 : {}),
318 },
319 }
320 : {}),
321 ...(t.ip.ipv6
322 ? {
323 ipv6: {
324 ipAddr: t.ip.ipv6.ipAddr ?? undefined,
325 ...(t.ip.ipv6.virtualNetworkId
326 ? { virtualNetworkId: t.ip.ipv6.virtualNetworkId }
327 : {}),
328 },
329 }
330 : {}),
331});
332
333/**
334 * Compare two address blocks. A `virtualNetworkId` left undefined in the
335 * desired state matches any observed value (Cloudflare fills in the
336 * account's default virtual network).
337 */
338const ipEquals = (observed: ResolvedIp, desired: ResolvedIp): boolean =>
339 familyEquals(observed.ipv4, desired.ipv4) &&
340 familyEquals(observed.ipv6, desired.ipv6);
341
342const familyEquals = (
343 observed: { ipAddr?: string; virtualNetworkId?: string } | undefined,
344 desired: { ipAddr?: string; virtualNetworkId?: string } | undefined,
345): boolean => {
346 if (desired === undefined) return observed === undefined;
347 if (observed === undefined) return false;
348 if (observed.ipAddr !== desired.ipAddr) return false;
349 if (
350 desired.virtualNetworkId !== undefined &&
351 observed.virtualNetworkId !== desired.virtualNetworkId
352 ) {
353 return false;
354 }
355 return true;
356};
357
358const toAttributes = (
359 target: ObservedTarget,
360 accountId: string,
361): InfrastructureTargetAttributes => ({
362 targetId: target.id,
363 accountId,
364 hostname: target.hostname,
365 ip: observedIp(target),
366 createdAt: target.createdAt,
367 modifiedAt: target.modifiedAt,
368});