1import * as keylessCertificates from "@distilled.cloud/cloudflare/keyless-certificates";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.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";
12import { listAllZones } from "../Zone/lookup.ts";
13
14const TypeId = "Cloudflare.KeylessCertificate.KeylessCertificate" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * How the certificate chain is bundled when the certificate is served.
19 * Create-only — Cloudflare has no API to change the bundle method after
20 * upload, so changing it triggers a replacement.
21 */
22export type BundleMethod = "ubiquitous" | "optimal" | "force";
23
24/**
25 * Lifecycle status of a Keyless SSL configuration.
26 */
27export type Status =
28 | "active"
29 | "deleted"
30 // Keep the union open so new Cloudflare statuses aren't blocked by stale
31 // types.
32 | (string & {});
33
34/**
35 * Configuration for reaching the key server through a Cloudflare Tunnel
36 * instead of over the public internet.
37 */
38export interface Tunnel {
39 /**
40 * Private IP of the key server inside the tunnel's virtual network.
41 */
42 privateIp: string;
43 /**
44 * Identifier of the Cloudflare Tunnel virtual network the key server is
45 * reachable through (e.g. `VirtualNetwork.vnetId`).
46 */
47 vnetId: string;
48}
49
50export interface Props {
51 /**
52 * Zone the Keyless SSL certificate is uploaded to. Keyless SSL is a
53 * zone-level Enterprise feature.
54 *
55 * Immutable — moving a Keyless SSL configuration between zones triggers a
56 * replacement.
57 */
58 zoneId: string;
59 /**
60 * The zone's SSL certificate (or certificate and intermediates) in PEM
61 * format. The private key never leaves your key server.
62 *
63 * Immutable — the PATCH API has no certificate field, so changing the
64 * certificate triggers a replacement. Plain `string` (not `string`)
65 * so it is statically comparable inside `diff`.
66 */
67 certificate: string;
68 /**
69 * Hostname of the externally running gokeyless key server that holds the
70 * private key. Mutable — patched in place.
71 */
72 host: string;
73 /**
74 * Port Cloudflare uses to communicate with the key server. Mutable —
75 * patched in place.
76 * @default 24008
77 */
78 port?: number;
79 /**
80 * Human readable name for the Keyless SSL configuration. If omitted, a
81 * deterministic name is generated from the app, stage, and logical ID.
82 * Mutable — patched in place.
83 * @default ${app}-${stage}-${id}
84 */
85 name?: string;
86 /**
87 * How the certificate chain is bundled: `ubiquitous` (highest probability
88 * of broad trust), `optimal` (shortest chain, newest intermediates), or
89 * `force` (use the chain exactly as uploaded).
90 *
91 * Create-only — changing the bundle method triggers a replacement.
92 * @default "ubiquitous"
93 */
94 bundleMethod?: BundleMethod;
95 /**
96 * Whether the Keyless SSL configuration is on or off. Mutable — patched in
97 * place.
98 * @default true
99 */
100 enabled?: boolean;
101 /**
102 * Reach the key server through a Cloudflare Tunnel virtual network instead
103 * of the public internet.
104 *
105 * Adding or changing the tunnel is patched in place; removing a previously
106 * configured tunnel triggers a replacement (the PATCH API cannot clear it).
107 */
108 tunnel?: Tunnel;
109}
110
111export interface Attributes {
112 /** Cloudflare-assigned identifier of the Keyless SSL configuration. */
113 keylessCertificateId: string;
114 /** Zone the Keyless SSL configuration belongs to. */
115 zoneId: string;
116 /** Human readable name of the Keyless SSL configuration. */
117 name: string;
118 /** Hostname of the key server holding the private key. */
119 host: string;
120 /** Port Cloudflare uses to communicate with the key server. */
121 port: number;
122 /** Whether the Keyless SSL configuration is on or off. */
123 enabled: boolean;
124 /** Current lifecycle status of the Keyless SSL configuration. */
125 status: Status;
126 /** Permissions the requesting token has on this Keyless SSL. */
127 permissions: string[];
128 /** ISO8601 timestamp the Keyless SSL configuration was created. */
129 createdOn: string;
130 /** ISO8601 timestamp the Keyless SSL configuration was last modified. */
131 modifiedOn: string;
132 /** Tunnel configuration, when the key server is reached through a Cloudflare Tunnel. */
133 tunnel: { privateIp: string; vnetId: string } | undefined;
134}
135
136export type KeylessCertificate = Resource<
137 TypeId,
138 Props,
139 Attributes,
140 never,
141 Providers
142>;
143
144/**
145 * A zone-level Keyless SSL configuration — serve TLS for a certificate whose
146 * private key stays on your own key server instead of being uploaded to
147 * Cloudflare.
148 *
149 * You upload only the certificate (and intermediates); Cloudflare reaches out
150 * to an externally running [gokeyless](https://github.com/cloudflare/gokeyless)
151 * key server at `host:port` (optionally through a Cloudflare Tunnel) for every
152 * private-key operation.
153 *
154 * Keyless SSL is an **Enterprise-only** feature: on zones without the
155 * entitlement, creation fails with the typed `KeylessSslNotAvailable` error
156 * (Cloudflare code 1067).
157 *
158 * `host`, `port`, `name`, `enabled`, and `tunnel` are mutable in place;
159 * `certificate` and `bundleMethod` are create-only and trigger a replacement.
160 * ### Creating a Keyless SSL configuration
161 * **Example:** Basic key server over the public internet
162 * ```typescript
163 * const keyless = yield* Cloudflare.KeylessCertificate.KeylessCertificate("SiteKeyless", {
164 * zoneId: zone.zoneId,
165 * certificate: certPem, // PEM, private key stays on your key server
166 * host: "keyless.example.com",
167 * port: 24008,
168 * });
169 * ```
170 *
171 * **Example:** Read the certificate from disk
172 * ```typescript
173 * const fs = yield* FileSystem.FileSystem;
174 * const certificate = yield* fs.readFileString("certs/site.pem");
175 *
176 * const keyless = yield* Cloudflare.KeylessCertificate.KeylessCertificate("SiteKeyless", {
177 * zoneId: zone.zoneId,
178 * certificate,
179 * host: "keyless.example.com",
180 * });
181 * ```
182 *
183 * ### Reaching the key server through a Cloudflare Tunnel
184 * **Example:** Private key server on a tunnel virtual network
185 * ```typescript
186 * const vnet = yield* Cloudflare.Tunnel.VirtualNetwork("KeylessVnet", {});
187 *
188 * const keyless = yield* Cloudflare.KeylessCertificate.KeylessCertificate("SiteKeyless", {
189 * zoneId: zone.zoneId,
190 * certificate: certPem,
191 * host: "keyless.internal",
192 * port: 24008,
193 * tunnel: {
194 * privateIp: "10.0.0.10",
195 * vnetId: vnet.vnetId,
196 * },
197 * });
198 * ```
199 *
200 * ### Rotation
201 * **Example:** Rotate by changing the certificate
202 * ```typescript
203 * // `certificate` is create-only — changing it replaces the configuration:
204 * // the new one is created and the old one is deleted.
205 * const keyless = yield* Cloudflare.KeylessCertificate.KeylessCertificate("SiteKeyless", {
206 * zoneId: zone.zoneId,
207 * certificate: rotatedCertPem,
208 * host: "keyless.example.com",
209 * });
210 * ```
211 *
212 * @see https://developers.cloudflare.com/ssl/keyless-ssl/
213 *
214 * @resource
215 * @product Keyless Certificates
216 * @category SSL/TLS & Certificates
217 */
218export const KeylessCertificate = Resource<KeylessCertificate>(TypeId, {
219 aliases: ["Cloudflare.KeylessCertificate"],
220});
221
222/**
223 * Returns true if the given value is a KeylessCertificate resource.
224 */
225export const isKeylessCertificate = (
226 value: unknown,
227): value is KeylessCertificate =>
228 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
229
230export const KeylessCertificateProvider = () =>
231 Provider.succeed(KeylessCertificate, {
232 stables: ["keylessCertificateId", "zoneId", "createdOn"],
233
234 // Keyless SSL is zone-scoped (`/zones/{id}/keyless_certificates`) with no
235 // account-wide enumeration API. Fan out over every zone in the account,
236 // list its Keyless SSL configurations, and hydrate each into the exact
237 // `read` Attributes shape. Zones without the Enterprise entitlement reject
238 // the route with the typed `Forbidden` error — skip them.
239 list: Effect.fn(function* () {
240 const { accountId } = yield* yield* CloudflareEnvironment;
241 const zones = yield* listAllZones(accountId);
242 const rows = yield* Effect.forEach(
243 zones,
244 (zone) =>
245 keylessCertificates.listKeylessCertificates
246 .pages({ zoneId: zone.id })
247 .pipe(
248 Stream.runCollect,
249 Effect.map((chunk) =>
250 Array.from(chunk).flatMap((page) =>
251 (page.result ?? [])
252 .filter((keyless) => keyless.status !== "deleted")
253 .map((keyless) =>
254 toAttributes(
255 { ...keyless, permissions: [...keyless.permissions] },
256 zone.id,
257 ),
258 ),
259 ),
260 ),
261 // Non-entitled / plan-gated zones reject Keyless SSL listing.
262 Effect.catchTag("Forbidden", () => Effect.succeed([])),
263 ),
264 { concurrency: 10 },
265 );
266 return rows.flat();
267 }),
268
269 diff: Effect.fn(function* ({ olds, news }) {
270 // diff runs during plan — `news` may still contain unresolved Outputs.
271 if (!isResolved(news)) return undefined;
272 // No prior props to compare against — let the engine decide.
273 if (olds?.certificate === undefined) return undefined;
274 // The PATCH API has no certificate field — rotation replaces.
275 if (normalizePem(olds.certificate) !== normalizePem(news.certificate)) {
276 return { action: "replace" } as const;
277 }
278 // bundleMethod is create-only.
279 if (
280 (olds.bundleMethod ?? "ubiquitous") !==
281 (news.bundleMethod ?? "ubiquitous")
282 ) {
283 return { action: "replace" } as const;
284 }
285 // The PATCH API can set a tunnel but cannot clear one — removing it
286 // requires a replacement. (Adding/changing is an in-place update.)
287 if (olds.tunnel !== undefined && news.tunnel === undefined) {
288 return { action: "replace" } as const;
289 }
290 // zoneId is Input<string>; compare only once both are concrete.
291 if (
292 typeof olds.zoneId === "string" &&
293 typeof news.zoneId === "string" &&
294 olds.zoneId !== news.zoneId
295 ) {
296 return { action: "replace" } as const;
297 }
298 return undefined;
299 }),
300
301 read: Effect.fn(function* ({ id, output, olds }) {
302 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
303 if (zoneId === undefined) return undefined;
304
305 // Owned path: refresh by our persisted identifier. Cloudflare keeps a
306 // tombstone with status "deleted" for a while — report it as gone.
307 if (output?.keylessCertificateId) {
308 const observed = yield* getKeylessCertificate(
309 zoneId,
310 output.keylessCertificateId,
311 );
312 return observed ? toAttributes(observed, zoneId) : undefined;
313 }
314
315 // Cold read — recover from lost state by matching the deterministic
316 // physical name. Names are not unique on Cloudflare's side; an exact
317 // match on our generated/explicit name is the best identity we have.
318 const name = yield* createKeylessName(id, olds?.name);
319 const match = yield* findByName(zoneId, name);
320 return match ? toAttributes(match, zoneId) : undefined;
321 }),
322
323 reconcile: Effect.fn(function* ({ id, news, output }) {
324 // Inputs have been resolved to concrete strings by Plan.
325 const zoneId = news.zoneId as string;
326 const name = yield* createKeylessName(id, news.name);
327 const desiredTunnel = resolveTunnel(news.tunnel);
328
329 // 1. Observe — the identifier cached on `output` is a hint, not a
330 // guarantee: a missing (or tombstoned "deleted") configuration
331 // falls through to the name scan and then to create.
332 let observed = output?.keylessCertificateId
333 ? yield* getKeylessCertificate(zoneId, output.keylessCertificateId)
334 : undefined;
335
336 // 2. Fall back to scanning the zone for a configuration carrying our
337 // deterministic physical name (recovers from lost state).
338 if (!observed) {
339 observed = yield* findByName(zoneId, name);
340 }
341
342 // 3. Ensure — create when missing. Names are not unique on
343 // Cloudflare's side, so there is no AlreadyExists race to
344 // tolerate. On zones without the Enterprise entitlement this
345 // fails with the typed `KeylessSslNotAvailable` error.
346 if (!observed) {
347 observed = yield* keylessCertificates.createKeylessCertificate({
348 zoneId,
349 certificate: news.certificate,
350 host: news.host,
351 port: news.port ?? 24008,
352 name,
353 bundleMethod: news.bundleMethod,
354 tunnel: desiredTunnel,
355 });
356 }
357
358 // 4. Sync — diff observed cloud state against desired and patch only
359 // when something actually differs; skip the call on a no-op.
360 // `enabled` is create-time defaulted by Cloudflare, so it is only
361 // compared when explicitly configured.
362 const desired = {
363 name,
364 host: news.host,
365 port: news.port ?? 24008,
366 };
367 const dirty =
368 observed.name !== desired.name ||
369 observed.host !== desired.host ||
370 observed.port !== desired.port ||
371 (news.enabled !== undefined && observed.enabled !== news.enabled) ||
372 (desiredTunnel !== undefined &&
373 !sameTunnel(observed.tunnel ?? undefined, desiredTunnel));
374
375 if (dirty) {
376 observed = yield* keylessCertificates.patchKeylessCertificate({
377 zoneId,
378 keylessCertificateId: observed.id,
379 ...desired,
380 enabled: news.enabled,
381 tunnel: desiredTunnel,
382 });
383 }
384
385 return toAttributes(observed, zoneId);
386 }),
387
388 delete: Effect.fn(function* ({ output }) {
389 // Deleting an already-gone configuration answers with Cloudflare code
390 // 1005 ("Invalid or missing Keyless SSL") — convergence, not failure.
391 yield* keylessCertificates
392 .deleteKeylessCertificate({
393 zoneId: output.zoneId,
394 keylessCertificateId: output.keylessCertificateId,
395 })
396 .pipe(Effect.catchTag("KeylessCertificateNotFound", () => Effect.void));
397 }),
398 });
399
400// ---------------------------------------------------------------------------
401// API helpers
402// ---------------------------------------------------------------------------
403
404type ObservedKeyless = keylessCertificates.GetKeylessCertificateResponse;
405
406/**
407 * Read a Keyless SSL configuration by id, mapping "gone"
408 * (`KeylessCertificateNotFound`, Cloudflare error code 1005 "Invalid or
409 * missing Keyless SSL") and the "deleted" tombstone status to `undefined`.
410 */
411const getKeylessCertificate = (zoneId: string, keylessCertificateId: string) =>
412 keylessCertificates
413 .getKeylessCertificate({ zoneId, keylessCertificateId })
414 .pipe(
415 Effect.map((observed): ObservedKeyless | undefined =>
416 observed.status === "deleted" ? undefined : observed,
417 ),
418 Effect.catchTag("KeylessCertificateNotFound", () =>
419 Effect.succeed(undefined),
420 ),
421 );
422
423/**
424 * Find a non-deleted Keyless SSL configuration by exact name. Names are not
425 * unique on Cloudflare's side; if several carry the same name, pick the
426 * oldest for determinism.
427 */
428const findByName = (zoneId: string, name: string) =>
429 keylessCertificates.listKeylessCertificates.items({ zoneId }).pipe(
430 Stream.filter(
431 (keyless) => keyless.name === name && keyless.status !== "deleted",
432 ),
433 Stream.runCollect,
434 Effect.map((chunk) =>
435 Array.from(chunk)
436 .sort((a, b) => a.createdOn.localeCompare(b.createdOn))
437 .at(0),
438 ),
439 Effect.map(
440 (keyless): ObservedKeyless | undefined =>
441 keyless && {
442 ...keyless,
443 permissions: [...keyless.permissions],
444 },
445 ),
446 );
447
448const createKeylessName = (id: string, name: string | undefined) =>
449 Effect.gen(function* () {
450 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
451 });
452
453/** Normalize PEM content for comparison (CRLF + trailing-newline noise). */
454const normalizePem = (pem: string): string => pem.replace(/\r\n/g, "\n").trim();
455
456/**
457 * Resolve a tunnel prop to the concrete shape the API expects. Inputs have
458 * been resolved to concrete strings by Plan before reconcile runs.
459 */
460const resolveTunnel = (
461 tunnel: Tunnel | undefined,
462): { privateIp: string; vnetId: string } | undefined =>
463 tunnel === undefined
464 ? undefined
465 : {
466 privateIp: tunnel.privateIp as string,
467 vnetId: tunnel.vnetId as string,
468 };
469
470const sameTunnel = (
471 observed: { privateIp: string; vnetId: string } | undefined,
472 desired: { privateIp: string; vnetId: string },
473): boolean =>
474 observed !== undefined &&
475 observed.privateIp === desired.privateIp &&
476 observed.vnetId === desired.vnetId;
477
478const toAttributes = (
479 keyless: ObservedKeyless,
480 zoneId: string,
481): Attributes => ({
482 keylessCertificateId: keyless.id,
483 zoneId,
484 name: keyless.name,
485 host: keyless.host,
486 port: keyless.port,
487 enabled: keyless.enabled,
488 status: keyless.status as Status,
489 permissions: [...keyless.permissions],
490 createdOn: keyless.createdOn,
491 modifiedOn: keyless.modifiedOn,
492 tunnel: keyless.tunnel ?? undefined,
493});