| 1 | import * as keylessCertificates from "@distilled.cloud/cloudflare/keyless-certificates"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | import { listAllZones } from "../Zone/lookup.ts"; |
| 13 | |
| 14 | const TypeId = "Cloudflare.KeylessCertificate.KeylessCertificate" as const; |
| 15 | type 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 | */ |
| 22 | export type BundleMethod = "ubiquitous" | "optimal" | "force"; |
| 23 | |
| 24 | /** |
| 25 | * Lifecycle status of a Keyless SSL configuration. |
| 26 | */ |
| 27 | export 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 | */ |
| 38 | export 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 | |
| 50 | export 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 | |
| 111 | export 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 | |
| 136 | export 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 | */ |
| 218 | export const KeylessCertificate = Resource<KeylessCertificate>(TypeId, { |
| 219 | aliases: ["Cloudflare.KeylessCertificate"], |
| 220 | }); |
| 221 | |
| 222 | /** |
| 223 | * Returns true if the given value is a KeylessCertificate resource. |
| 224 | */ |
| 225 | export const isKeylessCertificate = ( |
| 226 | value: unknown, |
| 227 | ): value is KeylessCertificate => |
| 228 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 229 | |
| 230 | export 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 | |
| 404 | type 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 | */ |
| 411 | const 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 | */ |
| 428 | const 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 | |
| 448 | const 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). */ |
| 454 | const 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 | */ |
| 460 | const 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 | |
| 470 | const 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 | |
| 478 | const 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 | }); |