| 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.Certificate" 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 CertificateStatus = |
| 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 CertificateProps = { |
| 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 zone's leaf client certificate in PEM format, presented by |
| 42 | * Cloudflare to your origin when Authenticated Origin Pulls is enabled. |
| 43 | * Cannot be changed after upload — updating this property triggers a |
| 44 | * 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 CertificateAttributes = { |
| 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: CertificateStatus | undefined; |
| 61 | /** When the certificate expires. */ |
| 62 | expiresOn: string | undefined; |
| 63 | /** The certificate authority that issued the certificate. */ |
| 64 | issuer: string | undefined; |
| 65 | /** The type of hash used for the certificate signature. */ |
| 66 | signature: string | undefined; |
| 67 | /** When the certificate was uploaded to Cloudflare. */ |
| 68 | uploadedOn: string | undefined; |
| 69 | }; |
| 70 | |
| 71 | export type Certificate = Resource< |
| 72 | TypeId, |
| 73 | CertificateProps, |
| 74 | CertificateAttributes, |
| 75 | never, |
| 76 | Providers |
| 77 | >; |
| 78 | |
| 79 | /** |
| 80 | * A zone-level Authenticated Origin Pulls (AOP) client certificate |
| 81 | * (`/zones/{zone_id}/origin_tls_client_auth`). |
| 82 | * |
| 83 | * Uploads the client certificate Cloudflare presents to your origin when |
| 84 | * zone-level Authenticated Origin Pulls is enabled |
| 85 | * ({@link Setting}), letting the origin verify that |
| 86 | * requests really come from Cloudflare via mTLS. |
| 87 | * |
| 88 | * Certificates are immutable: there is no update API, so changing any |
| 89 | * property triggers a replacement. Deployment is asynchronous — the |
| 90 | * certificate starts in `pending_deployment` and becomes `active` within a |
| 91 | * few minutes; deletion likewise passes through `pending_deletion`. |
| 92 | * ### Uploading a certificate |
| 93 | * **Example:** Zone client certificate |
| 94 | * ```typescript |
| 95 | * const cert = yield* Cloudflare.OriginTlsClientAuth.Certificate("AopCert", { |
| 96 | * zoneId: zone.zoneId, |
| 97 | * certificate: clientCertPem, |
| 98 | * privateKey: yield* Config.Redacted("AOP_CLIENT_KEY"), |
| 99 | * }); |
| 100 | * ``` |
| 101 | * |
| 102 | * ### Enabling Authenticated Origin Pulls |
| 103 | * **Example:** Upload the certificate and turn AOP on |
| 104 | * ```typescript |
| 105 | * const cert = yield* Cloudflare.OriginTlsClientAuth.Certificate("AopCert", { |
| 106 | * zoneId: zone.zoneId, |
| 107 | * certificate: clientCertPem, |
| 108 | * privateKey: yield* Config.Redacted("AOP_CLIENT_KEY"), |
| 109 | * }); |
| 110 | * |
| 111 | * yield* Cloudflare.OriginTlsClientAuth.Setting("Aop", { |
| 112 | * zoneId: zone.zoneId, |
| 113 | * enabled: true, |
| 114 | * }); |
| 115 | * ``` |
| 116 | * |
| 117 | * @see https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/ |
| 118 | * |
| 119 | * @resource |
| 120 | * @product Origin TLS Client Auth |
| 121 | * @category SSL/TLS & Certificates |
| 122 | */ |
| 123 | export const Certificate = Resource<Certificate>(TypeId); |
| 124 | |
| 125 | /** |
| 126 | * Returns true if the given value is an Certificate |
| 127 | * resource. |
| 128 | */ |
| 129 | export const isCertificate = (value: unknown): value is Certificate => |
| 130 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 131 | |
| 132 | export const CertificateProvider = () => |
| 133 | Provider.succeed(Certificate, { |
| 134 | stables: ["certificateId", "zoneId"], |
| 135 | |
| 136 | diff: Effect.fn(function* ({ olds = {}, news }) { |
| 137 | if (!isResolved(news)) return undefined; |
| 138 | const o = olds as CertificateProps; |
| 139 | const n = news as CertificateProps; |
| 140 | // zoneId is Input<string>; compare only once both sides are concrete. |
| 141 | if ( |
| 142 | typeof o.zoneId === "string" && |
| 143 | typeof n.zoneId === "string" && |
| 144 | o.zoneId !== n.zoneId |
| 145 | ) { |
| 146 | return { action: "replace" } as const; |
| 147 | } |
| 148 | if ( |
| 149 | (o.certificate !== undefined && |
| 150 | normalizePem(o.certificate) !== normalizePem(n.certificate)) || |
| 151 | (o.privateKey !== undefined && |
| 152 | unwrap(o.privateKey) !== unwrap(n.privateKey)) |
| 153 | ) { |
| 154 | // There is no update API for zone client certificates — every change |
| 155 | // is a replacement. |
| 156 | return { action: "replace" } as const; |
| 157 | } |
| 158 | return undefined; |
| 159 | }), |
| 160 | |
| 161 | read: Effect.fn(function* ({ output, olds }) { |
| 162 | const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined); |
| 163 | if (!zoneId) return undefined; |
| 164 | if (output?.certificateId) { |
| 165 | const observed = yield* observeById(zoneId, output.certificateId); |
| 166 | if (observed) return toAttributes(observed, zoneId); |
| 167 | } |
| 168 | // Cold read — recover by listing and matching on the certificate PEM |
| 169 | // content (the only identity available; zone client certificates have |
| 170 | // no name or tags). A content match IS the desired state, so adoption |
| 171 | // is safe without an `Unowned` gate. |
| 172 | if (typeof olds?.certificate === "string") { |
| 173 | const match = yield* findByContent(zoneId, olds.certificate); |
| 174 | if (match) return toAttributes(match, zoneId); |
| 175 | } |
| 176 | return undefined; |
| 177 | }), |
| 178 | |
| 179 | reconcile: Effect.fn(function* ({ news, output }) { |
| 180 | // Inputs have been resolved to concrete strings by Plan. |
| 181 | const zoneId = news.zoneId as string; |
| 182 | |
| 183 | // 1. Observe — the certificate id is the stable identifier; fall |
| 184 | // through a CertificateNotFound (or a pending/completed deletion) |
| 185 | // to the list+content match so we recover from out-of-band deletes |
| 186 | // and partial state persistence. |
| 187 | let observed = output?.certificateId |
| 188 | ? yield* observeById(zoneId, output.certificateId) |
| 189 | : yield* findByContent(zoneId, news.certificate); |
| 190 | |
| 191 | // 2. Ensure — upload if missing. Cloudflare rejects uploading a PEM |
| 192 | // identical to a live certificate (code 1406); tolerate the race |
| 193 | // (or an orphaned prior upload) by re-listing and adopting the |
| 194 | // certificate with identical PEM content. Re-uploading the PEM of |
| 195 | // a certificate in `pending_deletion` resurrects it under the same |
| 196 | // id, so a destroy→deploy cycle converges naturally. |
| 197 | if (!observed) { |
| 198 | observed = yield* originTls |
| 199 | .createOriginTlsClientAuth({ |
| 200 | zoneId, |
| 201 | certificate: news.certificate, |
| 202 | privateKey: unwrap(news.privateKey), |
| 203 | }) |
| 204 | .pipe( |
| 205 | Effect.catchTag("CertificateAlreadyExists", (originalError) => |
| 206 | Effect.gen(function* () { |
| 207 | const match = yield* findByContent(zoneId, news.certificate); |
| 208 | if (!match) return yield* Effect.fail(originalError); |
| 209 | return match; |
| 210 | }), |
| 211 | ), |
| 212 | // A destroy→deploy cycle re-uploading the same PEM hits 1406 while |
| 213 | // the prior certificate's tombstone is still `pending_deletion` |
| 214 | // (it settles to `deleted` within ~10s, after which the same PEM |
| 215 | // either resurrects under its old id or uploads fresh). Ride that |
| 216 | // window out with a bounded retry of the whole create-or-adopt |
| 217 | // step. |
| 218 | // |
| 219 | // Cloudflare serializes zone client-cert mutations per zone; when a |
| 220 | // sibling upload/delete on the SAME zone is in flight, a concurrent |
| 221 | // upload is rejected with HTTP 409 (`ZoneClientCertConflict`). |
| 222 | // Retry it on the same bounded (~50s) schedule so concurrent |
| 223 | // per-zone mutations serialize gracefully instead of failing the |
| 224 | // deploy. |
| 225 | Effect.retry({ |
| 226 | while: (e) => |
| 227 | e._tag === "CertificateAlreadyExists" || |
| 228 | e._tag === "ZoneClientCertConflict", |
| 229 | schedule: Schedule.spaced("5 seconds"), |
| 230 | times: 10, |
| 231 | }), |
| 232 | ); |
| 233 | } |
| 234 | |
| 235 | // 3. Return — deployment is asynchronous (`pending_deployment` → |
| 236 | // `active`); we do not block on activation. |
| 237 | return toAttributes(observed, zoneId); |
| 238 | }), |
| 239 | |
| 240 | list: Effect.fn(function* () { |
| 241 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 242 | const zones = yield* listAllZones(accountId); |
| 243 | const rows = yield* Effect.forEach( |
| 244 | zones, |
| 245 | (zone) => |
| 246 | originTls.listOriginTlsClientAuths.pages({ zoneId: zone.id }).pipe( |
| 247 | Stream.runCollect, |
| 248 | Effect.map((chunk) => |
| 249 | Array.from(chunk).flatMap((page) => |
| 250 | (page.result ?? []) |
| 251 | // Match `read`: tombstoned (deleted / pending_deletion) |
| 252 | // certificates no longer satisfy the desired state. |
| 253 | .filter((cert) => isLive(cert.status)) |
| 254 | .map((cert) => toAttributes(cert, zone.id)), |
| 255 | ), |
| 256 | ), |
| 257 | // A zone the scoped token can't access rejects with the typed |
| 258 | // Forbidden tag; skip it rather than failing the whole enumeration. |
| 259 | Effect.catchTag("Forbidden", () => Effect.succeed([])), |
| 260 | ), |
| 261 | { concurrency: 10 }, |
| 262 | ); |
| 263 | return rows.flat(); |
| 264 | }), |
| 265 | |
| 266 | delete: Effect.fn(function* ({ output }) { |
| 267 | // Deletion is idempotent: a certificate that is already gone surfaces |
| 268 | // as `CertificateNotFound`, and one already in (or past) deletion |
| 269 | // answers HTTP 400 "Certificate is already deleted." |
| 270 | // (`CertificateAlreadyDeleted`). Both mean the desired end state — the |
| 271 | // certificate is not live — is reached. |
| 272 | yield* originTls |
| 273 | .deleteOriginTlsClientAuth({ |
| 274 | zoneId: output.zoneId, |
| 275 | certificateId: output.certificateId, |
| 276 | }) |
| 277 | .pipe( |
| 278 | // A certificate still propagating its initial deployment answers |
| 279 | // code 1434 "Cannot delete resource while in pending deployment |
| 280 | // state." — this is why a destroy immediately after a deploy could |
| 281 | // leak the certificate. Deployment settles within seconds, so ride |
| 282 | // it out with a bounded (~60s) retry. |
| 283 | // |
| 284 | // Cloudflare serializes zone client-cert mutations per zone; a delete |
| 285 | // racing a sibling upload/delete on the SAME zone is rejected with |
| 286 | // HTTP 409 (`ZoneClientCertConflict`). Retry it on the same bounded |
| 287 | // schedule so the delete (and thus teardown) converges instead of |
| 288 | // leaking the certificate. |
| 289 | Effect.retry({ |
| 290 | while: (e) => |
| 291 | e._tag === "CertificatePendingDeployment" || |
| 292 | e._tag === "ZoneClientCertConflict", |
| 293 | schedule: Schedule.spaced("5 seconds"), |
| 294 | times: 12, |
| 295 | }), |
| 296 | Effect.catchTag( |
| 297 | ["CertificateNotFound", "CertificateAlreadyDeleted"], |
| 298 | () => Effect.void, |
| 299 | ), |
| 300 | ); |
| 301 | }), |
| 302 | }); |
| 303 | |
| 304 | /** A certificate that is being or has been deleted no longer satisfies the desired state. */ |
| 305 | const isLive = (status: string | null | undefined): boolean => |
| 306 | status !== "deleted" && status !== "pending_deletion"; |
| 307 | |
| 308 | // Observe a certificate by id, treating "not found" and "deleted / |
| 309 | // pending_deletion" (Cloudflare keeps tombstones readable by id) as missing. |
| 310 | const observeById = (zoneId: string, certificateId: string) => |
| 311 | originTls.getOriginTlsClientAuth({ zoneId, certificateId }).pipe( |
| 312 | Effect.map((cert) => (isLive(cert.status) ? cert : undefined)), |
| 313 | Effect.catchTag("CertificateNotFound", () => Effect.succeed(undefined)), |
| 314 | ); |
| 315 | |
| 316 | // Locate a live certificate by exact PEM content. Tombstoned certificates |
| 317 | // are excluded so a destroy→deploy cycle re-creates (resurrects) instead of |
| 318 | // adopting a certificate that is on its way out. |
| 319 | const findByContent = (zoneId: string, certificate: string) => |
| 320 | Effect.gen(function* () { |
| 321 | const list = yield* originTls.listOriginTlsClientAuths({ zoneId }); |
| 322 | // Cloudflare returns `result: null` (not `[]`) for a zone whose cert store |
| 323 | // is empty — treat it as no matches. |
| 324 | return (list.result ?? []).find( |
| 325 | (c) => |
| 326 | isLive(c.status) && |
| 327 | normalizePem(c.certificate ?? "") === normalizePem(certificate), |
| 328 | ); |
| 329 | }); |
| 330 | |
| 331 | const normalizePem = (pem: string): string => pem.trim(); |
| 332 | |
| 333 | const unwrap = (value: Redacted.Redacted<string> | string): string => |
| 334 | Redacted.isRedacted(value) ? Redacted.value(value) : value; |
| 335 | |
| 336 | type CertificateShape = { |
| 337 | id?: string | null; |
| 338 | status?: string | null; |
| 339 | expiresOn?: string | null; |
| 340 | issuer?: string | null; |
| 341 | signature?: string | null; |
| 342 | uploadedOn?: string | null; |
| 343 | }; |
| 344 | |
| 345 | const toAttributes = ( |
| 346 | cert: CertificateShape, |
| 347 | zoneId: string, |
| 348 | ): CertificateAttributes => ({ |
| 349 | certificateId: cert.id!, |
| 350 | zoneId, |
| 351 | status: cert.status ?? undefined, |
| 352 | expiresOn: cert.expiresOn ?? undefined, |
| 353 | issuer: cert.issuer ?? undefined, |
| 354 | signature: cert.signature ?? undefined, |
| 355 | uploadedOn: cert.uploadedOn ?? undefined, |
| 356 | }); |