From 93e7723b486e389949a9625c0da17e32c785d304 Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 31 Jul 2025 00:10:18 -0400 Subject: [PATCH] feat(app-connections, PKI): Cloudflare as DNS provider --- .../cloudflare-connection-router.ts | 28 +++- .../cloudflare/cloudflare-connection-fns.ts | 24 ++- .../cloudflare-connection-service.ts | 26 ++- .../cloudflare/cloudflare-connection-types.ts | 5 + .../acme/acme-certificate-authority-enums.ts | 3 +- .../acme/acme-certificate-authority-fns.ts | 151 ++++++++---------- .../acme/dns-providers/cloudflare.ts | 109 +++++++++++++ .../acme/dns-providers/route54.ts | 75 +++++++++ .../api/appConnections/cloudflare/queries.tsx | 31 +++- .../api/appConnections/cloudflare/types.ts | 5 + frontend/src/hooks/api/ca/constants.tsx | 13 +- frontend/src/hooks/api/ca/enums.tsx | 3 +- frontend/src/hooks/api/ca/types.ts | 2 +- .../components/ExternalCaModal.tsx | 135 ++++++++++------ 14 files changed, 461 insertions(+), 149 deletions(-) create mode 100644 backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts create mode 100644 backend/src/services/certificate-authority/acme/dns-providers/route54.ts diff --git a/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts index d18d0564e..8a8d5deb1 100644 --- a/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/cloudflare-connection-router.ts @@ -46,7 +46,6 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi const { connectionId } = req.params; const projects = await server.services.appConnection.cloudflare.listPagesProjects(connectionId, req.permission); - return projects; } }); @@ -74,7 +73,34 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi const { connectionId } = req.params; const projects = await server.services.appConnection.cloudflare.listWorkersScripts(connectionId, req.permission); + return projects; + } + }); + server.route({ + method: "GET", + url: `/:connectionId/cloudflare-zones`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects = await server.services.appConnection.cloudflare.listZones(connectionId, req.permission); return projects; } }); diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts index d0ac070f3..02f304a7f 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-fns.ts @@ -10,7 +10,8 @@ import { TCloudflareConnection, TCloudflareConnectionConfig, TCloudflarePagesProject, - TCloudflareWorkersScript + TCloudflareWorkersScript, + TCloudflareZone } from "./cloudflare-connection-types"; export const getCloudflareConnectionListItem = () => { @@ -66,6 +67,27 @@ export const listCloudflareWorkersScripts = async ( })); }; +export const listCloudflareZones = async (appConnection: TCloudflareConnection): Promise => { + const { + credentials: { apiToken } + } = appConnection; + + const { data } = await request.get<{ result: { name: string; id: string }[] }>( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + + return data.result.map((a) => ({ + name: a.name, + id: a.id + })); +}; + export const validateCloudflareConnectionCredentials = async (config: TCloudflareConnectionConfig) => { const { apiToken, accountId } = config.credentials; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts index 5a8a161fc..c832ddb90 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-service.ts @@ -2,7 +2,11 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { listCloudflarePagesProjects, listCloudflareWorkersScripts } from "./cloudflare-connection-fns"; +import { + listCloudflarePagesProjects, + listCloudflareWorkersScripts, + listCloudflareZones +} from "./cloudflare-connection-fns"; import { TCloudflareConnection } from "./cloudflare-connection-types"; type TGetAppConnectionFunc = ( @@ -16,7 +20,6 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); try { const projects = await listCloudflarePagesProjects(appConnection); - return projects; } catch (error) { logger.error( @@ -30,9 +33,8 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF const listWorkersScripts = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); try { - const projects = await listCloudflareWorkersScripts(appConnection); - - return projects; + const scripts = await listCloudflareWorkersScripts(appConnection); + return scripts; } catch (error) { logger.error( error, @@ -42,8 +44,20 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF } }; + const listZones = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); + try { + const zones = await listCloudflareZones(appConnection); + return zones; + } catch (error) { + logger.error(error, `Failed to list Cloudflare Zones for Cloudflare connection [connectionId=${connectionId}]`); + return []; + } + }; + return { listPagesProjects, - listWorkersScripts + listWorkersScripts, + listZones }; }; diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts index 0ac1b708c..163bf6495 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-types.ts @@ -32,3 +32,8 @@ export type TCloudflarePagesProject = { export type TCloudflareWorkersScript = { id: string; }; + +export type TCloudflareZone = { + id: string; + name: string; +}; diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts index 9f6fbe752..c4703d49f 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts @@ -1,3 +1,4 @@ export enum AcmeDnsProvider { - Route53 = "route53" + Route53 = "route53", + Cloudflare = "cloudflare" } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index f6e77ac8e..96e4a1292 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -1,19 +1,17 @@ -import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53"; import * as x509 from "@peculiar/x509"; import acme from "acme-client"; import { TableName } from "@app/db/schemas"; -import { CustomAWSHasher } from "@app/lib/aws/hashing"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, CryptographyError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; -import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; -import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; -import { TAwsConnection, TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; +import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; +import { TCloudflareConnection } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -39,6 +37,8 @@ import { TCreateAcmeCertificateAuthorityDTO, TUpdateAcmeCertificateAuthorityDTO } from "./acme-certificate-authority-types"; +import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare"; +import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54"; type TAcmeCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; @@ -95,74 +95,6 @@ export const castDbEntryToAcmeCertificateAuthority = ( }; }; -export const route53InsertTxtRecord = async ( - connection: TAwsConnectionConfig, - hostedZoneId: string, - domain: string, - value: string -) => { - const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global - const route53Client = new Route53Client({ - sha256: CustomAWSHasher, - useFipsEndpoint: crypto.isFipsModeEnabled(), - credentials: config.credentials!, - region: config.region - }); - - const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: hostedZoneId, - ChangeBatch: { - Comment: "Set ACME challenge TXT record", - Changes: [ - { - Action: "UPSERT", - ResourceRecordSet: { - Name: domain, - Type: "TXT", - TTL: 30, - ResourceRecords: [{ Value: value }] - } - } - ] - } - }); - - await route53Client.send(command); -}; - -export const route53DeleteTxtRecord = async ( - connection: TAwsConnectionConfig, - hostedZoneId: string, - domain: string, - value: string -) => { - const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global - const route53Client = new Route53Client({ - credentials: config.credentials!, - region: config.region - }); - - const command = new ChangeResourceRecordSetsCommand({ - HostedZoneId: hostedZoneId, - ChangeBatch: { - Comment: "Delete ACME challenge TXT record", - Changes: [ - { - Action: "DELETE", - ResourceRecordSet: { - Name: domain, - Type: "TXT", - TTL: 30, - ResourceRecords: [{ Value: value }] - } - } - ] - } - }); - - await route53Client.send(command); -}; - export const AcmeCertificateAuthorityFns = ({ appConnectionDAL, appConnectionService, @@ -209,6 +141,12 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if (dnsProviderConfig.provider === AcmeDnsProvider.Cloudflare && appConnection.app !== AppConnection.Cloudflare) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a Cloudflare connection` + }); + } + // validates permission to connect await appConnectionService.connectAppConnectionById(appConnection.app as AppConnection, dnsAppConnectionId, actor); @@ -289,6 +227,15 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if ( + dnsProviderConfig.provider === AcmeDnsProvider.Cloudflare && + appConnection.app !== AppConnection.Cloudflare + ) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a Cloudflare connection` + }); + } + // validates permission to connect await appConnectionService.connectAppConnectionById( appConnection.app as AppConnection, @@ -443,26 +390,56 @@ export const AcmeCertificateAuthorityFns = ({ const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { - await route53InsertTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53InsertTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareInsertTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error("Unsupported DNS provider"); + } } }, challengeRemoveFn: async (authz, challenge, keyAuthorization) => { const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordValue = `"${keyAuthorization}"`; // must be double quoted - if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { - await route53DeleteTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53DeleteTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareDeleteTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error("Unsupported DNS provider"); + } } } }); diff --git a/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts b/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts new file mode 100644 index 000000000..0171b24b7 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/cloudflare.ts @@ -0,0 +1,109 @@ +import axios from "axios"; + +import { request } from "@app/lib/config/request"; +import { TCloudflareConnectionConfig } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +export const cloudflareInsertTxtRecord = async ( + connection: TCloudflareConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiToken } + } = connection; + + try { + await request.post( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${hostedZoneId}/dns_records`, + { + type: "TXT", + name: domain, + content: value, + ttl: 60, + proxied: false + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } catch (error) { + if (axios.isAxiosError(error)) { + const firstErrorMessage = ( + error.response?.data as { + errors?: { message: string }[]; + } + )?.errors?.[0]?.message; + if (firstErrorMessage) { + throw new Error(firstErrorMessage); + } + } + throw error; + } +}; + +export const cloudflareDeleteTxtRecord = async ( + connection: TCloudflareConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiToken } + } = connection; + + try { + const listRecordsResponse = await request.get<{ + result: { id: string; type: string; name: string; content: string }[]; + }>(`${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${hostedZoneId}/dns_records`, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + }, + params: { + type: "TXT", + name: domain, + content: value + } + }); + + const dnsRecords = listRecordsResponse.data?.result; + + if (Array.isArray(dnsRecords) && dnsRecords.length > 0) { + const recordToDelete = dnsRecords.find( + (record) => record.type === "TXT" && record.name === domain && record.content === value + ); + + if (recordToDelete) { + await request.delete( + `${IntegrationUrls.CLOUDFLARE_API_URL}/client/v4/zones/${hostedZoneId}/dns_records/${recordToDelete.id}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } + } + } catch (error) { + if (axios.isAxiosError(error)) { + const firstErrorMessage = ( + error.response?.data as { + errors?: { message: string }[]; + } + )?.errors?.[0]?.message; + if (firstErrorMessage) { + throw new Error(firstErrorMessage); + } + } + throw error; + } +}; diff --git a/backend/src/services/certificate-authority/acme/dns-providers/route54.ts b/backend/src/services/certificate-authority/acme/dns-providers/route54.ts new file mode 100644 index 000000000..4d235a6e0 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/route54.ts @@ -0,0 +1,75 @@ +import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53"; + +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; + +export const route53InsertTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + sha256: CustomAWSHasher, + useFipsEndpoint: crypto.isFipsModeEnabled(), + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Set ACME challenge TXT record", + Changes: [ + { + Action: "UPSERT", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; + +export const route53DeleteTxtRecord = async ( + connection: TAwsConnectionConfig, + hostedZoneId: string, + domain: string, + value: string +) => { + const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global + const route53Client = new Route53Client({ + credentials: config.credentials!, + region: config.region + }); + + const command = new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneId, + ChangeBatch: { + Comment: "Delete ACME challenge TXT record", + Changes: [ + { + Action: "DELETE", + ResourceRecordSet: { + Name: domain, + Type: "TXT", + TTL: 30, + ResourceRecords: [{ Value: value }] + } + } + ] + } + }); + + await route53Client.send(command); +}; diff --git a/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx index f4ca87ad1..c0eb75c19 100644 --- a/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx +++ b/frontend/src/hooks/api/appConnections/cloudflare/queries.tsx @@ -3,14 +3,16 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TCloudflarePagesProject, TCloudflareWorkersScript } from "./types"; +import { TCloudflarePagesProject, TCloudflareWorkersScript, TCloudflareZone } from "./types"; const cloudflareConnectionKeys = { all: [...appConnectionKeys.all, "cloudflare"] as const, listPagesProjects: (connectionId: string) => [...cloudflareConnectionKeys.all, "pages-projects", connectionId] as const, listWorkersScripts: (connectionId: string) => - [...cloudflareConnectionKeys.all, "workers-scripts", connectionId] as const + [...cloudflareConnectionKeys.all, "workers-scripts", connectionId] as const, + listWorkersZones: (connectionId: string) => + [...cloudflareConnectionKeys.all, "zones", connectionId] as const }; export const useCloudflareConnectionListPagesProjects = ( @@ -62,3 +64,28 @@ export const useCloudflareConnectionListWorkersScripts = ( ...options }); }; + +export const useCloudflareConnectionListZones = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TCloudflareZone[], + unknown, + TCloudflareZone[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: cloudflareConnectionKeys.listWorkersZones(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/cloudflare/${connectionId}/cloudflare-zones` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/cloudflare/types.ts b/frontend/src/hooks/api/appConnections/cloudflare/types.ts index 1d5ea53d4..e8905e16c 100644 --- a/frontend/src/hooks/api/appConnections/cloudflare/types.ts +++ b/frontend/src/hooks/api/appConnections/cloudflare/types.ts @@ -6,3 +6,8 @@ export type TCloudflarePagesProject = { export type TCloudflareWorkersScript = { id: string; }; + +export type TCloudflareZone = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 6d05b5ef3..fa59b3ae0 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,6 +1,7 @@ +import { AppConnection } from "../appConnections/enums"; import { SshCaStatus } from "../sshCa"; import { SshCertTemplateStatus } from "../sshCertificateTemplates"; -import { CaStatus, InternalCaType } from "./enums"; +import { AcmeDnsProvider, CaStatus, InternalCaType } from "./enums"; export const caTypeToNameMap: { [K in InternalCaType]: string } = { [InternalCaType.ROOT]: "Root", @@ -13,6 +14,16 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; +export const ACME_DNS_PROVIDER_NAME_MAP: Record = { + [AcmeDnsProvider.ROUTE53]: "Route53", + [AcmeDnsProvider.Cloudflare]: "Cloudflare" +}; + +export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record = { + [AcmeDnsProvider.ROUTE53]: AppConnection.AWS, + [AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare +}; + export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index b2b725612..b010faa07 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -19,5 +19,6 @@ export enum CaRenewalType { } export enum AcmeDnsProvider { - ROUTE53 = "route53" + ROUTE53 = "route53", + Cloudflare = "cloudflare" } diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index f3703fe52..f9f812cb2 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -11,7 +11,7 @@ export type TAcmeCertificateAuthority = { configuration: { dnsAppConnectionId: string; dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53; + provider: AcmeDnsProvider; hostedZoneId: string; }; directoryUrl: string; diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index b7d91a851..d318b357d 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -16,7 +16,10 @@ import { Switch } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; +import { + TAvailableAppConnection, + useListAvailableAppConnections +} from "@app/hooks/api/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AcmeDnsProvider, @@ -28,6 +31,16 @@ import { } from "@app/hooks/api/ca"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { slugSchema } from "@app/lib/schemas"; +import { + ACME_DNS_PROVIDER_APP_CONNECTION_MAP, + ACME_DNS_PROVIDER_NAME_MAP +} from "@app/hooks/api/ca/constants"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { + TCloudflareZone, + useCloudflareConnectionListZones +} from "@app/hooks/api/appConnections/cloudflare"; +import { SingleValue } from "react-select"; const schema = z .object({ @@ -42,7 +55,7 @@ const schema = z id: z.string(), name: z.string() }), - // currently specific to Route53 but can be extended to others by differentiating via the provider property + // currently specific to Route53 & Cloudflare but can be extended to others by differentiating via the provider property dnsProviderConfig: z.object({ provider: z.nativeEnum(AcmeDnsProvider), hostedZoneId: z.string() @@ -105,12 +118,29 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { const caType = watch("type"); const dnsProvider = watch("configuration.dnsProviderConfig.provider"); - const { data: availableConnections, isPending } = useListAvailableAppConnections( - AppConnection.AWS, - { + const { data: availableRoute53Connections, isPending: isRoute53Pending } = + useListAvailableAppConnections(AppConnection.AWS, { enabled: dnsProvider === AcmeDnsProvider.ROUTE53 - } - ); + }); + + const { data: availableCloudflareConnections, isPending: isCloudflarePending } = + useListAvailableAppConnections(AppConnection.Cloudflare, { + enabled: dnsProvider === AcmeDnsProvider.Cloudflare + }); + + const availableConnections: TAvailableAppConnection[] = [ + ...(availableRoute53Connections || []), + ...(availableCloudflareConnections || []) + ]; + + const isPending = isRoute53Pending || isCloudflarePending; + + const connection = watch("configuration.dnsAppConnection"); + + const { data: cloudflareZones = [], isPending: isZonesPending } = + useCloudflareConnectionListZones(connection.id, { + enabled: dnsProvider === AcmeDnsProvider.Cloudflare && !!connection.id + }); useEffect(() => { if (ca) { @@ -138,25 +168,6 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { } }); } - } else { - reset({ - type: CaType.ACME, - name: "", - status: CaStatus.ACTIVE, - enableDirectIssuance: true, - configuration: { - dnsAppConnection: { - id: "", - name: "" - }, - dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53, - hostedZoneId: "" - }, - directoryUrl: "", - accountEmail: "" - } - }); } }, [ca, availableConnections]); @@ -284,12 +295,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { className="w-full" isDisabled={Boolean(ca)} > - - Route53 - + {Object.values(AcmeDnsProvider).map((provider) => ( + + {ACME_DNS_PROVIDER_NAME_MAP[provider]} + + ))} )} @@ -297,7 +307,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { ( { control={control} name="configuration.dnsAppConnection" /> - ( - - - - )} - /> + {dnsProvider === AcmeDnsProvider.ROUTE53 ? ( + ( + + + + )} + /> + ) : dnsProvider === AcmeDnsProvider.Cloudflare ? ( + ( + + zone.id === value)} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + }} + options={cloudflareZones} + placeholder="Select a zone..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + ) : ( + <> + )}