import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust"; import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import { isResolved } from "../../Diff.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; import type { Providers } from "../Providers.ts"; const TypeId = "Cloudflare.Gateway.Certificate" as const; type TypeId = typeof TypeId; /** * Deployment status of the certificate on Cloudflare's edge. Gateway TLS * interception can use certificates in the `available` state. */ export type CertificateBindingStatus = | "pending_deployment" | "available" | "pending_deletion" | "inactive" | (string & {}); export interface CertificateProps { /** * Certificate validity period in days (range: 1–10,950 days / ~30 * years). Only settable at creation time — changing it triggers a * replacement (a new certificate is generated). * * @default 1825 */ validityPeriodDays?: number; /** * Whether the certificate should be activated (deployed to Cloudflare's * edge so Gateway TLS interception can use it). Activation typically * completes within seconds; the provider waits (bounded) for the * `available` binding status. Set `false` to keep or return the * certificate to the `inactive` state. * * @default true */ activate?: boolean; } export interface CertificateAttributes { /** UUID of the certificate, assigned by Cloudflare. */ certificateId: string; /** Cloudflare account that owns the certificate. */ accountId: string; /** Edge deployment status (`available` means usable for interception). */ bindingStatus: CertificateBindingStatus | undefined; /** The CA certificate PEM (read-only, generated by Cloudflare). */ certificate: string | undefined; /** SHA256 fingerprint of the certificate. */ fingerprint: string | undefined; /** * Whether Gateway TLS interception currently uses this certificate. * Configured via the Gateway configuration `certificate` setting, not * on the certificate itself. */ inUse: boolean | undefined; /** Organization that issued the certificate. */ issuerOrg: string | undefined; /** Certificate kind — Cloudflare-generated (`gateway_managed`) or BYO-PKI (`custom`). */ certificateType: string | undefined; /** ISO8601 expiry timestamp. */ expiresOn: string | undefined; /** ISO8601 creation timestamp. */ createdAt: string | undefined; } export type Certificate = Resource< TypeId, CertificateProps, CertificateAttributes, never, Providers >; /** * A Cloudflare Zero Trust Gateway certificate — a Cloudflare-generated CA * used by Gateway to inspect TLS traffic (HTTPS filtering, antivirus * scanning, browser isolation). The certificate body is generated by * Cloudflare; you only choose the validity period and whether it is * activated (deployed to the edge). * * To make Gateway actually intercept with this certificate, reference its * `certificateId` from the Gateway configuration's `certificate` setting * (see `Cloudflare.Gateway.Configuration`). * ### Creating a Certificate * **Example:** Activated certificate (default) * ```typescript * const cert = yield* Cloudflare.Gateway.Certificate("InspectionCa", {}); * // cert.bindingStatus === "available" once deployed to the edge * ``` * * **Example:** Short-lived, kept inactive * ```typescript * const cert = yield* Cloudflare.Gateway.Certificate("StagedCa", { * validityPeriodDays: 365, * activate: false, * }); * ``` * * ### Using the certificate for TLS interception * **Example:** Wire into the Gateway configuration * ```typescript * const cert = yield* Cloudflare.Gateway.Certificate("InspectionCa", {}); * yield* Cloudflare.Gateway.Configuration("Gateway", { * settings: { * tlsDecrypt: { enabled: true }, * certificate: { id: cert.certificateId }, * }, * }); * ``` * * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/user-side-certificates/ * * @resource * @product Gateway * @category Cloudflare One (Zero Trust) */ export const Certificate = Resource(TypeId); /** * Returns true if the given value is a Certificate resource. */ export const isCertificate = (value: unknown): value is Certificate => Predicate.hasProperty(value, "Type") && value.Type === TypeId; export const CertificateProvider = () => Provider.succeed(Certificate, { stables: [ "certificateId", "accountId", "certificate", "fingerprint", "issuerOrg", "certificateType", "expiresOn", "createdAt", ], diff: Effect.fn(function* ({ olds = {}, news, output }) { const { accountId } = yield* yield* CloudflareEnvironment; if (!isResolved(news)) return undefined; if ((output?.accountId ?? accountId) !== accountId) { return { action: "replace" } as const; } // The validity period is only settable at creation time. const o = olds as CertificateProps; if ( output !== undefined && (o.validityPeriodDays ?? 1825) !== (news.validityPeriodDays ?? 1825) ) { return { action: "replace" } as const; } return undefined; }), read: Effect.fn(function* ({ output }) { const { accountId } = yield* yield* CloudflareEnvironment; const acct = output?.accountId ?? accountId; // Certificates carry no name or tags, so there is no cold lookup — // without a cached id the resource is simply unknown. if (!output?.certificateId) return undefined; const observed = yield* getCertificate(acct, output.certificateId); if (!observed) return undefined; return toAttributes(observed, acct); }), // Account-scoped collection (pattern b): enumerate every Gateway // certificate in the ambient account. The list response already carries // the full certificate shape, so each row maps straight to the `read` // Attributes — no per-item hydration is required. list: Effect.fn(function* () { const { accountId } = yield* yield* CloudflareEnvironment; return yield* zeroTrust.listGatewayCertificates.pages({ accountId }).pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => (page.result ?? []) .map((cert) => toAttributes(cert, accountId)) // A certificate bound to the Gateway config (the active // inspection CA) can't be deactivated/deleted while in use // (`GatewayCertificateInUse`), and account-wide teardown does // not remove the singleton Gateway config — skip in-use certs. .filter((cert) => !cert.inUse), ), ), ); }), reconcile: Effect.fn(function* ({ news, output }) { const { accountId } = yield* yield* CloudflareEnvironment; // 1. Observe — the cached id is a hint; a vanished certificate // falls through to create. let observed = output?.certificateId ? yield* getCertificate(accountId, output.certificateId) : undefined; // 2. Ensure — generate the CA when missing. New certificates start // out `inactive`. if (!observed) { observed = yield* zeroTrust.createGatewayCertificate({ accountId, ...(news.validityPeriodDays !== undefined ? { validityPeriodDays: news.validityPeriodDays } : {}), }); } const certificateId = observed.id ?? ""; // 3. Sync — converge the activation state. Activation/deactivation // transit through pending_deployment / pending_deletion, so poll // (bounded) for the terminal status; in practice this settles in // a few seconds. const wantActive = news.activate ?? true; const status = observed.bindingStatus ?? undefined; if (wantActive && (status === undefined || status === "inactive")) { yield* zeroTrust.activateGatewayCertificate({ accountId, certificateId, }); } else if ( !wantActive && (status === "available" || status === "pending_deployment") ) { yield* zeroTrust.deactivateGatewayCertificate({ accountId, certificateId, }); } const desired = wantActive ? "available" : "inactive"; const final = yield* waitForStatus(accountId, certificateId, desired); return toAttributes(final ?? observed, accountId); }), delete: Effect.fn(function* ({ output }) { const { accountId, certificateId } = output; // Observe — a missing certificate means we're done. const observed = yield* getCertificate(accountId, certificateId); if (!observed) return; // Active certificates cannot be deleted (GatewayCertificateInUse, // Cloudflare code 2118) — deactivate first and wait (bounded) for // the `inactive` state. const status = observed.bindingStatus ?? undefined; if (status === "available" || status === "pending_deployment") { yield* zeroTrust .deactivateGatewayCertificate({ accountId, certificateId }) .pipe( Effect.catchTag("GatewayCertificateNotFound", () => Effect.void), ); yield* waitForStatus(accountId, certificateId, "inactive"); } yield* zeroTrust .deleteGatewayCertificate({ accountId, certificateId }) .pipe(Effect.catchTag("GatewayCertificateNotFound", () => Effect.void)); }), }); type ObservedCertificate = | zeroTrust.GetGatewayCertificateResponse | zeroTrust.CreateGatewayCertificateResponse | zeroTrust.DeactivateGatewayCertificateResponse | zeroTrust.ListGatewayCertificatesResponse["result"][number]; /** * Read a certificate by id, mapping "gone" (`GatewayCertificateNotFound`, * Cloudflare error code 2027) to `undefined`. */ const getCertificate = (accountId: string, certificateId: string) => zeroTrust.getGatewayCertificate({ accountId, certificateId }).pipe( Effect.map((c): zeroTrust.GetGatewayCertificateResponse | undefined => c), Effect.catchTag("GatewayCertificateNotFound", () => Effect.succeed(undefined), ), ); /** * Poll the certificate until it reaches the desired binding status. * Bounded — returns the last observation (which may still be pending) * rather than failing, so eventual consistency never wedges a deploy. */ const waitForStatus = ( accountId: string, certificateId: string, desired: "available" | "inactive", ) => getCertificate(accountId, certificateId).pipe( Effect.repeat({ schedule: Schedule.spaced("3 seconds"), until: (c) => c === undefined || c.bindingStatus === desired, times: 10, }), ); const toAttributes = ( cert: ObservedCertificate, accountId: string, ): CertificateAttributes => ({ certificateId: cert.id ?? "", accountId, bindingStatus: cert.bindingStatus ?? undefined, certificate: cert.certificate ?? undefined, fingerprint: cert.fingerprint ?? undefined, inUse: cert.inUse ?? undefined, issuerOrg: cert.issuerOrg ?? undefined, certificateType: cert.type ?? undefined, expiresOn: cert.expiresOn ?? undefined, createdAt: cert.createdAt ?? undefined, });