feat(app-connections, PKI): Cloudflare as DNS provider

This commit is contained in:
x032205
2025-07-31 00:10:18 -04:00
parent bec1fefee8
commit 93e7723b48
14 changed files with 461 additions and 149 deletions
@@ -46,7 +46,6 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi
const { connectionId } = req.params; const { connectionId } = req.params;
const projects = await server.services.appConnection.cloudflare.listPagesProjects(connectionId, req.permission); const projects = await server.services.appConnection.cloudflare.listPagesProjects(connectionId, req.permission);
return projects; return projects;
} }
}); });
@@ -74,7 +73,34 @@ export const registerCloudflareConnectionRouter = async (server: FastifyZodProvi
const { connectionId } = req.params; const { connectionId } = req.params;
const projects = await server.services.appConnection.cloudflare.listWorkersScripts(connectionId, req.permission); 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; return projects;
} }
}); });
@@ -10,7 +10,8 @@ import {
TCloudflareConnection, TCloudflareConnection,
TCloudflareConnectionConfig, TCloudflareConnectionConfig,
TCloudflarePagesProject, TCloudflarePagesProject,
TCloudflareWorkersScript TCloudflareWorkersScript,
TCloudflareZone
} from "./cloudflare-connection-types"; } from "./cloudflare-connection-types";
export const getCloudflareConnectionListItem = () => { export const getCloudflareConnectionListItem = () => {
@@ -66,6 +67,27 @@ export const listCloudflareWorkersScripts = async (
})); }));
}; };
export const listCloudflareZones = async (appConnection: TCloudflareConnection): Promise<TCloudflareZone[]> => {
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) => { export const validateCloudflareConnectionCredentials = async (config: TCloudflareConnectionConfig) => {
const { apiToken, accountId } = config.credentials; const { apiToken, accountId } = config.credentials;
@@ -2,7 +2,11 @@ import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums"; 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"; import { TCloudflareConnection } from "./cloudflare-connection-types";
type TGetAppConnectionFunc = ( type TGetAppConnectionFunc = (
@@ -16,7 +20,6 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF
const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor);
try { try {
const projects = await listCloudflarePagesProjects(appConnection); const projects = await listCloudflarePagesProjects(appConnection);
return projects; return projects;
} catch (error) { } catch (error) {
logger.error( logger.error(
@@ -30,9 +33,8 @@ export const cloudflareConnectionService = (getAppConnection: TGetAppConnectionF
const listWorkersScripts = async (connectionId: string, actor: OrgServiceActor) => { const listWorkersScripts = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor); const appConnection = await getAppConnection(AppConnection.Cloudflare, connectionId, actor);
try { try {
const projects = await listCloudflareWorkersScripts(appConnection); const scripts = await listCloudflareWorkersScripts(appConnection);
return scripts;
return projects;
} catch (error) { } catch (error) {
logger.error( logger.error(
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 { return {
listPagesProjects, listPagesProjects,
listWorkersScripts listWorkersScripts,
listZones
}; };
}; };
@@ -32,3 +32,8 @@ export type TCloudflarePagesProject = {
export type TCloudflareWorkersScript = { export type TCloudflareWorkersScript = {
id: string; id: string;
}; };
export type TCloudflareZone = {
id: string;
name: string;
};
@@ -1,3 +1,4 @@
export enum AcmeDnsProvider { export enum AcmeDnsProvider {
Route53 = "route53" Route53 = "route53",
Cloudflare = "cloudflare"
} }
@@ -1,19 +1,17 @@
import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53";
import * as x509 from "@peculiar/x509"; import * as x509 from "@peculiar/x509";
import acme from "acme-client"; import acme from "acme-client";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { CustomAWSHasher } from "@app/lib/aws/hashing";
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, CryptographyError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, CryptographyError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; 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 { decryptAppConnection } from "@app/services/app-connection/app-connection-fns";
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types";
import { TAwsConnection, TAwsConnectionConfig } 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 { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
@@ -39,6 +37,8 @@ import {
TCreateAcmeCertificateAuthorityDTO, TCreateAcmeCertificateAuthorityDTO,
TUpdateAcmeCertificateAuthorityDTO TUpdateAcmeCertificateAuthorityDTO
} from "./acme-certificate-authority-types"; } from "./acme-certificate-authority-types";
import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare";
import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54";
type TAcmeCertificateAuthorityFnsDeps = { type TAcmeCertificateAuthorityFnsDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
@@ -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 = ({ export const AcmeCertificateAuthorityFns = ({
appConnectionDAL, appConnectionDAL,
appConnectionService, 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 // validates permission to connect
await appConnectionService.connectAppConnectionById(appConnection.app as AppConnection, dnsAppConnectionId, actor); 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 // validates permission to connect
await appConnectionService.connectAppConnectionById( await appConnectionService.connectAppConnectionById(
appConnection.app as AppConnection, 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 recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
const recordValue = `"${keyAuthorization}"`; // must be double quoted const recordValue = `"${keyAuthorization}"`; // must be double quoted
if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { switch (acmeCa.configuration.dnsProviderConfig.provider) {
await route53InsertTxtRecord( case AcmeDnsProvider.Route53: {
connection as TAwsConnection, await route53InsertTxtRecord(
acmeCa.configuration.dnsProviderConfig.hostedZoneId, connection as TAwsConnection,
recordName, acmeCa.configuration.dnsProviderConfig.hostedZoneId,
recordValue 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) => { challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
const recordValue = `"${keyAuthorization}"`; // must be double quoted const recordValue = `"${keyAuthorization}"`; // must be double quoted
if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.Route53) { switch (acmeCa.configuration.dnsProviderConfig.provider) {
await route53DeleteTxtRecord( case AcmeDnsProvider.Route53: {
connection as TAwsConnection, await route53DeleteTxtRecord(
acmeCa.configuration.dnsProviderConfig.hostedZoneId, connection as TAwsConnection,
recordName, acmeCa.configuration.dnsProviderConfig.hostedZoneId,
recordValue 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");
}
} }
} }
}); });
@@ -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;
}
};
@@ -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);
};
@@ -3,14 +3,16 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "../queries"; import { appConnectionKeys } from "../queries";
import { TCloudflarePagesProject, TCloudflareWorkersScript } from "./types"; import { TCloudflarePagesProject, TCloudflareWorkersScript, TCloudflareZone } from "./types";
const cloudflareConnectionKeys = { const cloudflareConnectionKeys = {
all: [...appConnectionKeys.all, "cloudflare"] as const, all: [...appConnectionKeys.all, "cloudflare"] as const,
listPagesProjects: (connectionId: string) => listPagesProjects: (connectionId: string) =>
[...cloudflareConnectionKeys.all, "pages-projects", connectionId] as const, [...cloudflareConnectionKeys.all, "pages-projects", connectionId] as const,
listWorkersScripts: (connectionId: string) => 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 = ( export const useCloudflareConnectionListPagesProjects = (
@@ -62,3 +64,28 @@ export const useCloudflareConnectionListWorkersScripts = (
...options ...options
}); });
}; };
export const useCloudflareConnectionListZones = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TCloudflareZone[],
unknown,
TCloudflareZone[],
ReturnType<typeof cloudflareConnectionKeys.listWorkersZones>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: cloudflareConnectionKeys.listWorkersZones(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TCloudflareZone[]>(
`/api/v1/app-connections/cloudflare/${connectionId}/cloudflare-zones`
);
return data;
},
...options
});
};
@@ -6,3 +6,8 @@ export type TCloudflarePagesProject = {
export type TCloudflareWorkersScript = { export type TCloudflareWorkersScript = {
id: string; id: string;
}; };
export type TCloudflareZone = {
id: string;
name: string;
};
+12 -1
View File
@@ -1,6 +1,7 @@
import { AppConnection } from "../appConnections/enums";
import { SshCaStatus } from "../sshCa"; import { SshCaStatus } from "../sshCa";
import { SshCertTemplateStatus } from "../sshCertificateTemplates"; import { SshCertTemplateStatus } from "../sshCertificateTemplates";
import { CaStatus, InternalCaType } from "./enums"; import { AcmeDnsProvider, CaStatus, InternalCaType } from "./enums";
export const caTypeToNameMap: { [K in InternalCaType]: string } = { export const caTypeToNameMap: { [K in InternalCaType]: string } = {
[InternalCaType.ROOT]: "Root", [InternalCaType.ROOT]: "Root",
@@ -13,6 +14,16 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = {
[CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate"
}; };
export const ACME_DNS_PROVIDER_NAME_MAP: Record<AcmeDnsProvider, string> = {
[AcmeDnsProvider.ROUTE53]: "Route53",
[AcmeDnsProvider.Cloudflare]: "Cloudflare"
};
export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record<AcmeDnsProvider, AppConnection> = {
[AcmeDnsProvider.ROUTE53]: AppConnection.AWS,
[AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare
};
export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => {
switch (status) { switch (status) {
case CaStatus.ACTIVE: case CaStatus.ACTIVE:
+2 -1
View File
@@ -19,5 +19,6 @@ export enum CaRenewalType {
} }
export enum AcmeDnsProvider { export enum AcmeDnsProvider {
ROUTE53 = "route53" ROUTE53 = "route53",
Cloudflare = "cloudflare"
} }
+1 -1
View File
@@ -11,7 +11,7 @@ export type TAcmeCertificateAuthority = {
configuration: { configuration: {
dnsAppConnectionId: string; dnsAppConnectionId: string;
dnsProviderConfig: { dnsProviderConfig: {
provider: AcmeDnsProvider.ROUTE53; provider: AcmeDnsProvider;
hostedZoneId: string; hostedZoneId: string;
}; };
directoryUrl: string; directoryUrl: string;
@@ -16,7 +16,10 @@ import {
Switch Switch
} from "@app/components/v2"; } from "@app/components/v2";
import { useWorkspace } from "@app/context"; 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 { AppConnection } from "@app/hooks/api/appConnections/enums";
import { import {
AcmeDnsProvider, AcmeDnsProvider,
@@ -28,6 +31,16 @@ import {
} from "@app/hooks/api/ca"; } from "@app/hooks/api/ca";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { slugSchema } from "@app/lib/schemas"; 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 const schema = z
.object({ .object({
@@ -42,7 +55,7 @@ const schema = z
id: z.string(), id: z.string(),
name: 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({ dnsProviderConfig: z.object({
provider: z.nativeEnum(AcmeDnsProvider), provider: z.nativeEnum(AcmeDnsProvider),
hostedZoneId: z.string() hostedZoneId: z.string()
@@ -105,12 +118,29 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
const caType = watch("type"); const caType = watch("type");
const dnsProvider = watch("configuration.dnsProviderConfig.provider"); const dnsProvider = watch("configuration.dnsProviderConfig.provider");
const { data: availableConnections, isPending } = useListAvailableAppConnections( const { data: availableRoute53Connections, isPending: isRoute53Pending } =
AppConnection.AWS, useListAvailableAppConnections(AppConnection.AWS, {
{
enabled: dnsProvider === AcmeDnsProvider.ROUTE53 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(() => { useEffect(() => {
if (ca) { 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]); }, [ca, availableConnections]);
@@ -284,12 +295,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
className="w-full" className="w-full"
isDisabled={Boolean(ca)} isDisabled={Boolean(ca)}
> >
<SelectItem {Object.values(AcmeDnsProvider).map((provider) => (
value={String(AcmeDnsProvider.ROUTE53)} <SelectItem value={String(provider)} key={provider}>
key={AcmeDnsProvider.ROUTE53} {ACME_DNS_PROVIDER_NAME_MAP[provider]}
> </SelectItem>
Route53 ))}
</SelectItem>
</Select> </Select>
</FormControl> </FormControl>
)} )}
@@ -297,7 +307,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller <Controller
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
tooltipText={`${dnsProvider === AcmeDnsProvider.ROUTE53 ? "Route53" : ""} requires an AWS App Connection. This can be created from the Organization Settings page.`} tooltipText={`${ACME_DNS_PROVIDER_NAME_MAP[dnsProvider]} uses the ${APP_CONNECTION_MAP[ACME_DNS_PROVIDER_APP_CONNECTION_MAP[dnsProvider]].name} App Connection. You can create one in the Organization Settings page.`}
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
label="DNS App Connection" label="DNS App Connection"
@@ -318,21 +328,50 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
control={control} control={control}
name="configuration.dnsAppConnection" name="configuration.dnsAppConnection"
/> />
<Controller {dnsProvider === AcmeDnsProvider.ROUTE53 ? (
control={control} <Controller
defaultValue="" control={control}
name="configuration.dnsProviderConfig.hostedZoneId" defaultValue=""
render={({ field, fieldState: { error } }) => ( name="configuration.dnsProviderConfig.hostedZoneId"
<FormControl render={({ field, fieldState: { error } }) => (
label="Hosted Zone ID" <FormControl
isError={Boolean(error)} label="Hosted Zone ID"
errorText={error?.message} isError={Boolean(error)}
isRequired errorText={error?.message}
> isRequired
<Input {...field} placeholder="Z040441124N1GOOMCQYX1" /> >
</FormControl> <Input {...field} placeholder="Z040441124N1GOOMCQYX1" />
)} </FormControl>
/> )}
/>
) : dnsProvider === AcmeDnsProvider.Cloudflare ? (
<Controller
name="configuration.dnsProviderConfig.hostedZoneId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Zone"
>
<FilterableSelect
isLoading={isZonesPending && !!connection.id}
isDisabled={!connection.id}
value={cloudflareZones.find((zone) => zone.id === value)}
onChange={(option) => {
onChange((option as SingleValue<TCloudflareZone>)?.id ?? null);
}}
options={cloudflareZones}
placeholder="Select a zone..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
) : (
<></>
)}
<Controller <Controller
control={control} control={control}
defaultValue="" defaultValue=""