| 1 | import * as originTls from "@distilled.cloud/cloudflare/origin-tls-client-auth"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Redacted from "effect/Redacted"; |
| 5 | import * as Schedule from "effect/Schedule"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | |
| 8 | import { isResolved } from "../../Diff.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 12 | import type { Providers } from "../Providers.ts"; |
| 13 | import { listAllZones } from "../Zone/lookup.ts"; |
| 14 | |
| 15 | const TypeId = "Cloudflare.OriginTlsClientAuth.HostnameCertificate" as const; |
| 16 | type TypeId = typeof TypeId; |
| 17 | |
| 18 | /** |
| 19 | * Deployment status of the certificate. Deploying and deleting are |
| 20 | * asynchronous (`pending_deployment` → `active`, `pending_deletion` → |
| 21 | * `deleted`), typically settling within minutes. |
| 22 | */ |
| 23 | export type HostnameCertificateStatus = |
| 24 | | "initializing" |
| 25 | | "pending_deployment" |
| 26 | | "pending_deletion" |
| 27 | | "active" |
| 28 | | "deleted" |
| 29 | | "deployment_timed_out" |
| 30 | | "deletion_timed_out" |
| 31 | // Keep the union open so new Cloudflare statuses aren't blocked by stale types. |
| 32 | | (string & {}); |
| 33 | |
| 34 | export type HostnameCertificateProps = { |
| 35 | /** |
| 36 | * Zone the certificate is uploaded to. Cannot be changed after upload — |
| 37 | * updating this property triggers a replacement. |
| 38 | */ |
| 39 | zoneId: string; |
| 40 | /** |
| 41 | * The per-hostname client certificate in PEM format, presented by |
| 42 | * Cloudflare to your origin for hostnames associated with it via |
| 43 | * {@link HostnameAssociation}. Cannot be changed after |
| 44 | * upload — updating this property triggers a replacement. |
| 45 | */ |
| 46 | certificate: string; |
| 47 | /** |
| 48 | * The certificate's private key in PEM format. Cannot be changed after |
| 49 | * upload — updating this property triggers a replacement. |
| 50 | */ |
| 51 | privateKey: Redacted.Redacted<string>; |
| 52 | }; |
| 53 | |
| 54 | export type HostnameCertificateAttributes = { |
| 55 | /** Unique identifier of the uploaded certificate. */ |
| 56 | certificateId: string; |
| 57 | /** Zone the certificate is uploaded to. */ |
| 58 | zoneId: string; |
| 59 | /** Deployment status of the certificate. */ |
| 60 | status: HostnameCertificateStatus | undefined; |
| 61 | /** When the certificate expires. */ |
| 62 | expiresOn: string | undefined; |
| 63 | /** The certificate authority that issued the certificate. */ |
| 64 | issuer: string | undefined; |
| 65 | /** The serial number on the uploaded certificate. */ |
| 66 | serialNumber: string | undefined; |
| 67 | /** The type of hash used for the certificate signature. */ |
| 68 | signature: string | undefined; |
| 69 | /** When the certificate was uploaded to Cloudflare. */ |
| 70 | uploadedOn: string | undefined; |
| 71 | }; |
| 72 | |
| 73 | export type HostnameCertificate = Resource< |
| 74 | TypeId, |
| 75 | HostnameCertificateProps, |
| 76 | HostnameCertificateAttributes, |
| 77 | never, |
| 78 | Providers |
| 79 | >; |
| 80 | |
| 81 | /** |
| 82 | * A per-hostname Authenticated Origin Pulls (AOP) client certificate |
| 83 | * (`/zones/{zone_id}/origin_tls_client_auth/hostnames/certificates`). |
| 84 | * |
| 85 | * Uploads a client certificate that Cloudflare presents to your origin for |
| 86 | * specific hostnames. Hostnames opt in by referencing the certificate from an |
| 87 | * {@link HostnameAssociation}, which pins the certificate |
| 88 | * and enables hostname-level AOP. |
| 89 | * |
| 90 | * Certificates are immutable: there is no update API, so changing any |
| 91 | * property triggers a replacement. Deployment is asynchronous — the |
| 92 | * certificate starts in `pending_deployment` and becomes `active` within a |
| 93 | * few minutes; deletion likewise passes through `pending_deletion`. |
| 94 | * ### Uploading a hostname certificate |
| 95 | * **Example:** Hostname client certificate |
| 96 | * ```typescript |
| 97 | * const cert = yield* Cloudflare.OriginTlsClientAuth.HostnameCertificate("AopHostCert", { |
| 98 | * zoneId: zone.zoneId, |
| 99 | * certificate: clientCertPem, |
| 100 | * privateKey: yield* Config.Redacted("AOP_CLIENT_KEY"), |
| 101 | * }); |
| 102 | * ``` |
| 103 | * |
| 104 | * ### Enabling AOP for a hostname |
| 105 | * **Example:** Upload the certificate and associate a hostname |
| 106 | * ```typescript |
| 107 | * const cert = yield* Cloudflare.OriginTlsClientAuth.HostnameCertificate("AopHostCert", { |
| 108 | * zoneId: zone.zoneId, |
| 109 | * certificate: clientCertPem, |
| 110 | * privateKey: yield* Config.Redacted("AOP_CLIENT_KEY"), |
| 111 | * }); |
| 112 | * |
| 113 | * yield* Cloudflare.OriginTlsClientAuth.HostnameAssociation("AopHost", { |
| 114 | * zoneId: zone.zoneId, |
| 115 | * hostname: "api.example.com", |
| 116 | * certId: cert.certificateId, |
| 117 | * enabled: true, |
| 118 | * }); |
| 119 | * ``` |
| 120 | * |
| 121 | * @see https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/set-up/per-hostname/ |
| 122 | * |
| 123 | * @resource |
| 124 | * @product Origin TLS Client Auth |
| 125 | * @category SSL/TLS & Certificates |
| 126 | */ |
| 127 | export const HostnameCertificate = Resource<HostnameCertificate>(TypeId); |
| 128 | |
| 129 | /** |
| 130 | * Returns true if the given value is an HostnameCertificate |
| 131 | * resource. |
| 132 | */ |
| 133 | export const isHostnameCertificate = ( |
| 134 | value: unknown, |
| 135 | ): value is HostnameCertificate => |
| 136 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 137 | |
| 138 | export const HostnameCertificateProvider = () => |
| 139 | Provider.succeed(HostnameCertificate, { |
| 140 | stables: ["certificateId", "zoneId"], |
| 141 | |
| 142 | list: Effect.fn(function* () { |
| 143 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 144 | // Per-hostname client certificates live inside a zone and are only |
| 145 | // enumerable per-zone — fan out over every zone in the account. |
| 146 | const zones = yield* listAllZones(accountId); |
| 147 | const rows = yield* Effect.forEach( |
| 148 | zones, |
| 149 | (zone) => |
| 150 | originTls.listHostnameCertificates.pages({ zoneId: zone.id }).pipe( |
| 151 | Stream.runCollect, |
| 152 | Effect.map((chunk) => |
| 153 | Array.from(chunk).flatMap((page) => |
| 154 | (page.result ?? []) |
| 155 | // Match `read`: tombstoned (deleted / pending_deletion) |
| 156 | // certificates no longer satisfy the desired state. |
| 157 | .filter((c) => isLive(c.status)) |
| 158 | .map((c) => toAttributes(c, zone.id)), |
| 159 | ), |
| 160 | ), |
| 161 | // Plan-gated / partial zones reject the route; skip them. |
| 162 | Effect.catchTag("Forbidden", () => Effect.succeed([])), |
| 163 | ), |
| 164 | { concurrency: 10 }, |
| 165 | ); |
| 166 | return rows.flat(); |
| 167 | }), |
| 168 | |
| 169 | diff: Effect.fn(function* ({ olds, news }) { |
| 170 | if (!isResolved(news)) return undefined; |
| 171 | // zoneId is Input<string>; compare only once both sides are concrete. |
| 172 | if ( |
| 173 | typeof olds.zoneId === "string" && |
| 174 | typeof news.zoneId === "string" && |
| 175 | olds.zoneId !== news.zoneId |
| 176 | ) { |
| 177 | return { action: "replace" } as const; |
| 178 | } |
| 179 | // Compare the certificate only when the old value is known — an |
| 180 | // Output-valued `certificate` doesn't survive a `creating`-state |
| 181 | // round-trip (it deserializes as `undefined`). |
| 182 | if ( |
| 183 | (olds.certificate !== undefined && |
| 184 | normalizePem(olds.certificate) !== normalizePem(news.certificate)) || |
| 185 | unwrap(olds.privateKey) !== unwrap(news.privateKey) |
| 186 | ) { |
| 187 | // There is no update API for hostname client certificates — every |
| 188 | // change is a replacement. |
| 189 | return { action: "replace" } as const; |
| 190 | } |
| 191 | return undefined; |
| 192 | }), |
| 193 | |
| 194 | read: Effect.fn(function* ({ output, olds }) { |
| 195 | const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined); |
| 196 | if (!zoneId) return undefined; |
| 197 | if (output?.certificateId) { |
| 198 | const observed = yield* observeById(zoneId, output.certificateId); |
| 199 | if (observed) return toAttributes(observed, zoneId); |
| 200 | } |
| 201 | // Cold read — recover by listing and matching on the certificate PEM |
| 202 | // content (the only identity available; hostname client certificates |
| 203 | // have no name or tags). A content match IS the desired state, so |
| 204 | // adoption is safe without an `Unowned` gate. |
| 205 | if (typeof olds?.certificate === "string") { |
| 206 | const match = yield* findByContent(zoneId, olds.certificate); |
| 207 | if (match) return toAttributes(match, zoneId); |
| 208 | } |
| 209 | return undefined; |
| 210 | }), |
| 211 | |
| 212 | reconcile: Effect.fn(function* ({ news, output }) { |
| 213 | // Inputs have been resolved to concrete strings by Plan. |
| 214 | const zoneId = news.zoneId as string; |
| 215 | |
| 216 | // 1. Observe — the certificate id is the stable identifier; fall |
| 217 | // through a HostnameCertificateNotFound (or a pending/completed |
| 218 | // deletion) to the list+content match so we recover from |
| 219 | // out-of-band deletes and partial state persistence. |
| 220 | let observed = output?.certificateId |
| 221 | ? yield* observeById(zoneId, output.certificateId) |
| 222 | : yield* findByContent(zoneId, news.certificate); |
| 223 | |
| 224 | // 2. Ensure — upload if missing. Cloudflare rejects uploading a PEM |
| 225 | // identical to an existing certificate (code 1406); tolerate the |
| 226 | // race (or an orphaned prior upload) by re-listing and adopting the |
| 227 | // certificate with identical PEM content. A destroy→deploy cycle |
| 228 | // also hits 1406 while the old certificate's tombstone is still |
| 229 | // `pending_deletion` (observed live: it settles to `deleted` within |
| 230 | // ~10 seconds, after which re-uploading the PEM resurrects it under |
| 231 | // the same id) — ride that window out with a bounded retry of the |
| 232 | // whole create-or-adopt step. |
| 233 | if (!observed) { |
| 234 | observed = yield* originTls |
| 235 | .createHostnameCertificate({ |
| 236 | zoneId, |
| 237 | certificate: news.certificate, |
| 238 | privateKey: unwrap(news.privateKey), |
| 239 | }) |
| 240 | .pipe( |
| 241 | Effect.catchTag("CertificateAlreadyExists", (originalError) => |
| 242 | Effect.gen(function* () { |
| 243 | const match = yield* findByContent(zoneId, news.certificate); |
| 244 | if (!match) return yield* Effect.fail(originalError); |
| 245 | return match; |
| 246 | }), |
| 247 | ), |
| 248 | Effect.retry({ |
| 249 | while: (e) => e._tag === "CertificateAlreadyExists", |
| 250 | schedule: Schedule.spaced("5 seconds"), |
| 251 | times: 10, |
| 252 | }), |
| 253 | ); |
| 254 | } |
| 255 | |
| 256 | // 3. Return — deployment is asynchronous (`pending_deployment` → |
| 257 | // `active`); we do not block on activation. |
| 258 | return toAttributes(observed, zoneId); |
| 259 | }), |
| 260 | |
| 261 | delete: Effect.fn(function* ({ output }) { |
| 262 | yield* originTls |
| 263 | .deleteHostnameCertificate({ |
| 264 | zoneId: output.zoneId, |
| 265 | certificateId: output.certificateId, |
| 266 | }) |
| 267 | .pipe( |
| 268 | // The certificate cannot be deleted while a hostname association |
| 269 | // still references it (code 1433) or while its initial deployment |
| 270 | // is still propagating (code 1434). Both clear within seconds once |
| 271 | // the association is voided / the deployment lands — ride them out |
| 272 | // with a bounded retry. |
| 273 | Effect.retry({ |
| 274 | while: (e) => |
| 275 | e._tag === "HostnameCertificateInUse" || |
| 276 | e._tag === "CertificatePendingDeployment", |
| 277 | schedule: Schedule.spaced("5 seconds"), |
| 278 | times: 10, |
| 279 | }), |
| 280 | // Already gone (404 / code 1551) or already on its way out |
| 281 | // (code 1414 pending deletion) — delete is idempotent. |
| 282 | Effect.catchTag( |
| 283 | ["HostnameCertificateNotFound", "CertificatePendingDeletion"], |
| 284 | () => Effect.void, |
| 285 | ), |
| 286 | ); |
| 287 | }), |
| 288 | }); |
| 289 | |
| 290 | /** A certificate that is being or has been deleted no longer satisfies the desired state. */ |
| 291 | const isLive = (status: string | null | undefined): boolean => |
| 292 | status !== "deleted" && status !== "pending_deletion"; |
| 293 | |
| 294 | // Observe a certificate by id, treating "not found" and "deleted / |
| 295 | // pending_deletion" (Cloudflare keeps tombstones readable by id) as missing. |
| 296 | const observeById = (zoneId: string, certificateId: string) => |
| 297 | originTls.getHostnameCertificate({ zoneId, certificateId }).pipe( |
| 298 | Effect.map((cert) => (isLive(cert.status) ? cert : undefined)), |
| 299 | Effect.catchTag("HostnameCertificateNotFound", () => |
| 300 | Effect.succeed(undefined), |
| 301 | ), |
| 302 | ); |
| 303 | |
| 304 | // Locate a live certificate by exact PEM content. Tombstoned certificates |
| 305 | // are excluded so a destroy→deploy cycle re-creates (resurrects) instead of |
| 306 | // adopting a certificate that is on its way out. |
| 307 | const findByContent = (zoneId: string, certificate: string) => |
| 308 | Effect.gen(function* () { |
| 309 | const list = yield* originTls.listHostnameCertificates({ zoneId }); |
| 310 | return list.result.find( |
| 311 | (c) => |
| 312 | isLive(c.status) && |
| 313 | normalizePem(c.certificate ?? "") === normalizePem(certificate), |
| 314 | ); |
| 315 | }); |
| 316 | |
| 317 | const normalizePem = (pem: string): string => pem.trim(); |
| 318 | |
| 319 | const unwrap = (value: Redacted.Redacted<string> | string): string => |
| 320 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 321 | |
| 322 | type CertificateShape = { |
| 323 | id?: string | null; |
| 324 | status?: string | null; |
| 325 | expiresOn?: string | null; |
| 326 | issuer?: string | null; |
| 327 | serialNumber?: string | null; |
| 328 | signature?: string | null; |
| 329 | uploadedOn?: string | null; |
| 330 | }; |
| 331 | |
| 332 | const toAttributes = ( |
| 333 | cert: CertificateShape, |
| 334 | zoneId: string, |
| 335 | ): HostnameCertificateAttributes => ({ |
| 336 | certificateId: cert.id!, |
| 337 | zoneId, |
| 338 | status: cert.status ?? undefined, |
| 339 | expiresOn: cert.expiresOn ?? undefined, |
| 340 | issuer: cert.issuer ?? undefined, |
| 341 | serialNumber: cert.serialNumber ?? undefined, |
| 342 | signature: cert.signature ?? undefined, |
| 343 | uploadedOn: cert.uploadedOn ?? undefined, |
| 344 | }); |