mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 04:27:38 +00:00
feat(app-connections, PKI): Cloudflare as DNS provider
This commit is contained in:
+27
-1
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ export enum CaRenewalType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export enum AcmeDnsProvider {
|
export enum AcmeDnsProvider {
|
||||||
ROUTE53 = "route53"
|
ROUTE53 = "route53",
|
||||||
|
Cloudflare = "cloudflare"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+87
-48
@@ -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=""
|
||||||
|
|||||||
Reference in New Issue
Block a user