Merge pull request #4912 from Infisical/PKI-58-add-dns-made-easy-for-external-ca-acme

[PKI-58] Add dns made easy for external ca acme
This commit is contained in:
Fang-Pen Lin
2025-11-25 11:39:59 -08:00
committed by GitHub
35 changed files with 1221 additions and 23 deletions

View File

@@ -1,6 +1,7 @@
Feature: External CA
Scenario: Issue a certificate from an external CA
@cloudflare
Scenario Outline: Issue a certificate from an external CA with Cloudflare
Given I create a Cloudflare connection as cloudflare
Then I memorize cloudflare with jq ".appConnection.id" as app_conn_id
Given I create a external ACME CA with the following config as ext_ca
@@ -92,9 +93,7 @@ Feature: External CA
When I create certificate signing request as csr
Then I add names to certificate signing request csr
"""
{
"COMMON_NAME": "localhost"
}
<subject>
"""
# Pebble has a strict rule to only takes SANs
Then I add subject alternative name to certificate signing request csr
@@ -177,4 +176,196 @@ Feature: External CA
[
"localhost"
]
"""
"""
Examples:
| subject |
| {"COMMON_NAME": "localhost"} |
| {} |
@dnsme
Scenario Outline: Issue a certificate from an external CA with DNS Made Easy
Given I create a DNS Made Easy connection as dnsme
Then I memorize dnsme with jq ".appConnection.id" as app_conn_id
Given I create a external ACME CA with the following config as ext_ca
"""
{
"dnsProviderConfig": {
"provider": "dns-made-easy",
"hostedZoneId": "MOCK_ZONE_ID"
},
"directoryUrl": "{PEBBLE_URL}",
"accountEmail": "fangpen@infisical.com",
"dnsAppConnectionId": "{app_conn_id}",
"eabKid": "",
"eabHmacKey": ""
}
"""
Then I memorize ext_ca with jq ".id" as ext_ca_id
Given I create a certificate template with the following config as cert_template
"""
{
"subject": [
{
"type": "common_name",
"allowed": [
"*"
]
}
],
"sans": [
{
"type": "dns_name",
"allowed": [
"*"
]
}
],
"keyUsages": {
"required": [],
"allowed": [
"digital_signature",
"key_encipherment",
"non_repudiation",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only"
]
},
"extendedKeyUsages": {
"required": [],
"allowed": [
"client_auth",
"server_auth",
"code_signing",
"email_protection",
"ocsp_signing",
"time_stamping"
]
},
"algorithms": {
"signature": [
"SHA256-RSA",
"SHA512-RSA",
"SHA384-ECDSA",
"SHA384-RSA",
"SHA256-ECDSA",
"SHA512-ECDSA"
],
"keyAlgorithm": [
"RSA-2048",
"RSA-4096",
"ECDSA-P384",
"RSA-3072",
"ECDSA-P256",
"ECDSA-P521"
]
},
"validity": {
"max": "365d"
}
}
"""
Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id
Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile"
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
"""
<subject>
"""
# Pebble has a strict rule to only takes SANs
Then I add subject alternative name to certificate signing request csr
"""
[
"localhost"
]
"""
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I select challenge with type http-01 for domain localhost from order in order as challenge
And I serve challenge response for challenge at localhost
And I tell ACME server that challenge is ready to be verified
Given I intercept outgoing requests
"""
[
{
"scope": "https://api.dnsmadeeasy.com:443",
"method": "POST",
"path": "/V2.0/dns/managed/MOCK_ZONE_ID/records",
"status": 201,
"response": {
"gtdLocation": "DEFAULT",
"failed": false,
"monitor": false,
"failover": false,
"sourceId": 895364,
"dynamicDns": false,
"hardLink": false,
"ttl": 60,
"source": 1,
"name": "_acme-challenge",
"value": "\"MOCK_HTTP_01_VALUE\"",
"id": 12345678,
"type": "TXT"
},
"responseIsBinary": false
},
{
"scope": "https://api.dnsmadeeasy.com:443",
"method": "GET",
"path": "/V2.0/dns/managed/MOCK_ZONE_ID/records?type=TXT&recordName=_acme-challenge&page=0",
"status": 200,
"response": {
"totalRecords": 1,
"totalPages": 1,
"data": [
{
"gtdLocation": "DEFAULT",
"failed": false,
"monitor": false,
"failover": false,
"sourceId": 895364,
"dynamicDns": false,
"hardLink": false,
"ttl": 60,
"source": 1,
"name": "_acme-challenge",
"value": "\"MOCK_CHALLENGE_VALUE\"",
"id": 1111111,
"type": "TXT"
}
],
"page": 0
},
"responseIsBinary": false
},
{
"scope": "https://api.dnsmadeeasy.com:443",
"method": "DELETE",
"path": "/V2.0/dns/managed/MOCK_ZONE_ID/records/1111111",
"status": 200,
"response": "",
"responseIsBinary": false
}
]
"""
Then I poll and finalize the ACME order order as finalized_order
And the value finalized_order.body with jq ".status" should be equal to "valid"
And I parse the full-chain certificate from order finalized_order as cert
And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json
"""
[
"localhost"
]
"""
Examples:
| subject |
| {"COMMON_NAME": "localhost"} |
| {} |

View File

@@ -147,6 +147,40 @@ def step_impl(context: Context, var_name: str):
context.vars[var_name] = response
@given("I create a DNS Made Easy connection as {var_name}")
def step_impl(context: Context, var_name: str):
jwt_token = context.vars["AUTH_TOKEN"]
conn_slug = faker.slug()
with with_nocks(
context,
definitions=[
{
"scope": "https://api.dnsmadeeasy.com:443",
"method": "GET",
"path": "/V2.0/dns/managed/",
"status": 200,
"response": {"totalRecords": 0, "totalPages": 1, "data": [], "page": 0},
"responseIsBinary": False,
}
],
):
response = context.http_client.post(
"/api/v1/app-connections/dns-made-easy",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json={
"name": conn_slug,
"description": "",
"method": "api-key-secret",
"credentials": {
"apiKey": "MOCK_API_KEY",
"secretKey": "MOCK_SECRET_KEY",
},
},
)
response.raise_for_status()
context.vars[var_name] = response
@given("I create a external ACME CA with the following config as {var_name}")
def step_impl(context: Context, var_name: str):
jwt_token = context.vars["AUTH_TOKEN"]

View File

@@ -776,7 +776,9 @@ export const pkiAcmeServiceFactory = ({
const cert = await orderCertificate(
{
caId: certificateAuthority!.id,
commonName: certificateRequest.commonName!,
// It is possible that the CSR does not have a common name, in which case we use an empty string
// (more likely than not for a CSR from a modern ACME client like certbot, cert-manager, etc.)
commonName: certificateRequest.commonName ?? "",
altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value),
csr: Buffer.from(csrPem),
// TODO: not 100% sure what are these columns for, but let's put the values for common website SSL certs for now

View File

@@ -119,6 +119,7 @@ const envSchema = z
})
.default("{}")
),
DNS_MADE_EASY_SANDBOX_ENABLED: zodStrBool.default("false").optional(),
// smtp options
SMTP_HOST: zpStr(z.string().optional()),
SMTP_IGNORE_TLS: zodStrBool.default("false"),

View File

@@ -61,6 +61,10 @@ import {
DigitalOceanConnectionListItemSchema,
SanitizedDigitalOceanConnectionSchema
} from "@app/services/app-connection/digital-ocean";
import {
DNSMadeEasyConnectionListItemSchema,
SanitizedDNSMadeEasyConnectionSchema
} from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-schema";
import { FlyioConnectionListItemSchema, SanitizedFlyioConnectionSchema } from "@app/services/app-connection/flyio";
import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp";
import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github";
@@ -170,7 +174,8 @@ const SanitizedAppConnectionSchema = z.union([
...SanitizedAzureADCSConnectionSchema.options,
...SanitizedRedisConnectionSchema.options,
...SanitizedLaravelForgeConnectionSchema.options,
...SanitizedChefConnectionSchema.options
...SanitizedChefConnectionSchema.options,
...SanitizedDNSMadeEasyConnectionSchema.options
]);
const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
@@ -215,7 +220,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
AzureADCSConnectionListItemSchema,
RedisConnectionListItemSchema,
LaravelForgeConnectionListItemSchema,
ChefConnectionListItemSchema
ChefConnectionListItemSchema,
DNSMadeEasyConnectionListItemSchema
]);
export const registerAppConnectionRouter = async (server: FastifyZodProvider) => {

View File

@@ -0,0 +1,51 @@
import z from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateDNSMadeEasyConnectionSchema,
SanitizedDNSMadeEasyConnectionSchema,
UpdateDNSMadeEasyConnectionSchema
} from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-schema";
import { AuthMode } from "@app/services/auth/auth-type";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerDNSMadeEasyConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.DNSMadeEasy,
server,
sanitizedResponseSchema: SanitizedDNSMadeEasyConnectionSchema,
createSchema: CreateDNSMadeEasyConnectionSchema,
updateSchema: UpdateDNSMadeEasyConnectionSchema
});
// The below endpoints are not exposed and for Infisical App use
server.route({
method: "GET",
url: `/:connectionId/dns-made-easy-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 zones = await server.services.appConnection.dnsMadeEasy.listZones(connectionId, req.permission);
return zones;
}
});
};

View File

@@ -16,6 +16,7 @@ import { registerCamundaConnectionRouter } from "./camunda-connection-router";
import { registerChecklyConnectionRouter } from "./checkly-connection-router";
import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router";
import { registerDatabricksConnectionRouter } from "./databricks-connection-router";
import { registerDNSMadeEasyConnectionRouter } from "./dns-made-easy-connection-router";
import { registerDigitalOceanConnectionRouter } from "./digital-ocean-connection-router";
import { registerFlyioConnectionRouter } from "./flyio-connection-router";
import { registerGcpConnectionRouter } from "./gcp-connection-router";
@@ -78,6 +79,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server:
[AppConnection.Flyio]: registerFlyioConnectionRouter,
[AppConnection.GitLab]: registerGitLabConnectionRouter,
[AppConnection.Cloudflare]: registerCloudflareConnectionRouter,
[AppConnection.DNSMadeEasy]: registerDNSMadeEasyConnectionRouter,
[AppConnection.Bitbucket]: registerBitbucketConnectionRouter,
[AppConnection.Zabbix]: registerZabbixConnectionRouter,
[AppConnection.Railway]: registerRailwayConnectionRouter,

View File

@@ -29,6 +29,7 @@ export enum AppConnection {
Flyio = "flyio",
GitLab = "gitlab",
Cloudflare = "cloudflare",
DNSMadeEasy = "dns-made-easy",
Zabbix = "zabbix",
Railway = "railway",
Bitbucket = "bitbucket",

View File

@@ -88,6 +88,11 @@ import {
getDigitalOceanConnectionListItem,
validateDigitalOceanConnectionCredentials
} from "./digital-ocean";
import { DNSMadeEasyConnectionMethod } from "./dns-made-easy/dns-made-easy-connection-enum";
import {
getDNSMadeEasyConnectionListItem,
validateDNSMadeEasyConnectionCredentials
} from "./dns-made-easy/dns-made-easy-connection-fns";
import { FlyioConnectionMethod, getFlyioConnectionListItem, validateFlyioConnectionCredentials } from "./flyio";
import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp";
import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github";
@@ -171,7 +176,8 @@ const PKI_APP_CONNECTIONS = [
AppConnection.Cloudflare,
AppConnection.AzureADCS,
AppConnection.AzureKeyVault,
AppConnection.Chef
AppConnection.Chef,
AppConnection.DNSMadeEasy
];
export const listAppConnectionOptions = (projectType?: ProjectType) => {
@@ -207,6 +213,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => {
getFlyioConnectionListItem(),
getGitLabConnectionListItem(),
getCloudflareConnectionListItem(),
getDNSMadeEasyConnectionListItem(),
getZabbixConnectionListItem(),
getRailwayConnectionListItem(),
getBitbucketConnectionListItem(),
@@ -339,6 +346,7 @@ export const validateAppConnectionCredentials = async (
[AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.DNSMadeEasy]: validateDNSMadeEasyConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Railway]: validateRailwayConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator,
@@ -395,6 +403,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case OktaConnectionMethod.ApiToken:
case LaravelForgeConnectionMethod.ApiToken:
return "API Token";
case DNSMadeEasyConnectionMethod.APIKeySecret:
return "API Key & Secret";
case PostgresConnectionMethod.UsernameAndPassword:
case MsSqlConnectionMethod.UsernameAndPassword:
case MySqlConnectionMethod.UsernameAndPassword:
@@ -483,6 +493,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.Flyio]: platformManagedCredentialsNotSupported,
[AppConnection.GitLab]: platformManagedCredentialsNotSupported,
[AppConnection.Cloudflare]: platformManagedCredentialsNotSupported,
[AppConnection.DNSMadeEasy]: platformManagedCredentialsNotSupported,
[AppConnection.Zabbix]: platformManagedCredentialsNotSupported,
[AppConnection.Railway]: platformManagedCredentialsNotSupported,
[AppConnection.Bitbucket]: platformManagedCredentialsNotSupported,

View File

@@ -32,6 +32,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.Flyio]: "Fly.io",
[AppConnection.GitLab]: "GitLab",
[AppConnection.Cloudflare]: "Cloudflare",
[AppConnection.DNSMadeEasy]: "DNS Made Easy",
[AppConnection.Zabbix]: "Zabbix",
[AppConnection.Railway]: "Railway",
[AppConnection.Bitbucket]: "Bitbucket",
@@ -77,6 +78,7 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.Flyio]: AppConnectionPlanType.Regular,
[AppConnection.GitLab]: AppConnectionPlanType.Regular,
[AppConnection.Cloudflare]: AppConnectionPlanType.Regular,
[AppConnection.DNSMadeEasy]: AppConnectionPlanType.Regular,
[AppConnection.Zabbix]: AppConnectionPlanType.Regular,
[AppConnection.Railway]: AppConnectionPlanType.Regular,
[AppConnection.Bitbucket]: AppConnectionPlanType.Regular,

View File

@@ -72,6 +72,8 @@ import { checklyConnectionService } from "./checkly/checkly-connection-service";
import { ValidateCloudflareConnectionCredentialsSchema } from "./cloudflare/cloudflare-connection-schema";
import { cloudflareConnectionService } from "./cloudflare/cloudflare-connection-service";
import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks";
import { ValidateDNSMadeEasyConnectionCredentialsSchema } from "./dns-made-easy/dns-made-easy-connection-schema";
import { dnsMadeEasyConnectionService } from "./dns-made-easy/dns-made-easy-connection-service";
import { databricksConnectionService } from "./databricks/databricks-connection-service";
import { ValidateDigitalOceanConnectionCredentialsSchema } from "./digital-ocean";
import { digitalOceanAppPlatformConnectionService } from "./digital-ocean/digital-ocean-connection-service";
@@ -167,6 +169,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.Flyio]: ValidateFlyioConnectionCredentialsSchema,
[AppConnection.GitLab]: ValidateGitLabConnectionCredentialsSchema,
[AppConnection.Cloudflare]: ValidateCloudflareConnectionCredentialsSchema,
[AppConnection.DNSMadeEasy]: ValidateDNSMadeEasyConnectionCredentialsSchema,
[AppConnection.Zabbix]: ValidateZabbixConnectionCredentialsSchema,
[AppConnection.Railway]: ValidateRailwayConnectionCredentialsSchema,
[AppConnection.Bitbucket]: ValidateBitbucketConnectionCredentialsSchema,
@@ -875,6 +878,7 @@ export const appConnectionServiceFactory = ({
flyio: flyioConnectionService(connectAppConnectionById),
gitlab: gitlabConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
cloudflare: cloudflareConnectionService(connectAppConnectionById),
dnsMadeEasy: dnsMadeEasyConnectionService(connectAppConnectionById),
zabbix: zabbixConnectionService(connectAppConnectionById),
railway: railwayConnectionService(connectAppConnectionById),
bitbucket: bitbucketConnectionService(connectAppConnectionById),

View File

@@ -15,8 +15,8 @@ import {
TOracleDBConnectionInput,
TValidateOracleDBConnectionCredentialsSchema
} from "@app/ee/services/app-connections/oracledb";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
@@ -106,6 +106,12 @@ import {
TDigitalOceanConnectionInput,
TValidateDigitalOceanCredentialsSchema
} from "./digital-ocean";
import {
TDNSMadeEasyConnection,
TDNSMadeEasyConnectionConfig,
TDNSMadeEasyConnectionInput,
TValidateDNSMadeEasyConnectionCredentialsSchema
} from "./dns-made-easy/dns-made-easy-connection-types";
import {
TFlyioConnection,
TFlyioConnectionConfig,
@@ -279,6 +285,7 @@ export type TAppConnection = { id: string } & (
| TGitLabConnection
| TCloudflareConnection
| TBitbucketConnection
| TDNSMadeEasyConnection
| TZabbixConnection
| TRailwayConnection
| TChecklyConnection
@@ -328,6 +335,7 @@ export type TAppConnectionInput = { id: string } & (
| TGitLabConnectionInput
| TCloudflareConnectionInput
| TBitbucketConnectionInput
| TDNSMadeEasyConnectionInput
| TZabbixConnectionInput
| TRailwayConnectionInput
| TChecklyConnectionInput
@@ -395,6 +403,7 @@ export type TAppConnectionConfig =
| TGitLabConnectionConfig
| TCloudflareConnectionConfig
| TBitbucketConnectionConfig
| TDNSMadeEasyConnectionConfig
| TZabbixConnectionConfig
| TRailwayConnectionConfig
| TChecklyConnectionConfig
@@ -439,6 +448,7 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateGitLabConnectionCredentialsSchema
| TValidateCloudflareConnectionCredentialsSchema
| TValidateBitbucketConnectionCredentialsSchema
| TValidateDNSMadeEasyConnectionCredentialsSchema
| TValidateZabbixConnectionCredentialsSchema
| TValidateRailwayConnectionCredentialsSchema
| TValidateChecklyConnectionCredentialsSchema

View File

@@ -0,0 +1,3 @@
export enum DNSMadeEasyConnectionMethod {
APIKeySecret = "api-key-secret"
}

View File

@@ -0,0 +1,221 @@
import { AxiosError } from "axios";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { DNSMadeEasyConnectionMethod } from "./dns-made-easy-connection-enum";
import {
TDNSMadeEasyConnection,
TDNSMadeEasyConnectionConfig,
TDNSMadeEasyZone
} from "./dns-made-easy-connection-types";
interface DNSMadeEasyApiResponse {
totalRecords: number;
totalPages: number;
data: Array<{
id: number;
name: string;
type: string;
value: string;
}>;
page: number;
}
export const getDNSMadeEasyUrl = (path: string) => {
const appCfg = getConfig();
return `${appCfg.DNS_MADE_EASY_SANDBOX_ENABLED ? IntegrationUrls.DNS_MADE_EASY_SANDBOX_API_URL : IntegrationUrls.DNS_MADE_EASY_API_URL}${path}`;
};
export const makeDNSMadeEasyAuthHeaders = (
apiKey: string,
secretKey: string,
currentDate?: Date
): Record<string, string> => {
// Format date as "Day, DD Mon YYYY HH:MM:SS GMT" (e.g., "Mon, 01 Jan 2024 12:00:00 GMT")
const requestDate = (currentDate ?? new Date()).toUTCString();
// Generate HMAC-SHA1 signature
const hmac = crypto.nativeCrypto.createHmac("sha1", secretKey);
hmac.update(requestDate);
const hmacSignature = hmac.digest("hex");
return {
"x-dnsme-apiKey": apiKey,
"x-dnsme-hmac": hmacSignature,
"x-dnsme-requestDate": requestDate
};
};
export const getDNSMadeEasyConnectionListItem = () => {
return {
name: "DNS Made Easy" as const,
app: AppConnection.DNSMadeEasy as const,
methods: Object.values(DNSMadeEasyConnectionMethod) as [DNSMadeEasyConnectionMethod.APIKeySecret]
};
};
export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection): Promise<TDNSMadeEasyZone[]> => {
if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) {
throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" });
}
const {
credentials: { apiKey, secretKey }
} = appConnection;
try {
const allZones: TDNSMadeEasyZone[] = [];
let currentPage = 0;
let totalPages = 1;
// Fetch all pages of zones
while (currentPage < totalPages) {
// eslint-disable-next-line no-await-in-loop
const resp = await request.get<DNSMadeEasyApiResponse>(getDNSMadeEasyUrl("/V2.0/dns/managed/"), {
headers: {
...makeDNSMadeEasyAuthHeaders(apiKey, secretKey),
Accept: "application/json"
},
params: {
page: currentPage
}
});
if (resp.data?.data) {
// Map the API response to TDNSMadeEasyZone format
const zones = resp.data.data.map((zone) => ({
id: String(zone.id),
name: zone.name
}));
allZones.push(...zones);
// Update pagination info
totalPages = resp.data.totalPages || 1;
currentPage += 1;
} else {
break;
}
}
return allZones;
} catch (error: unknown) {
logger.error(error, "Error listing DNS Made Easy zones");
if (error instanceof AxiosError) {
throw new BadRequestError({
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
message: `Failed to list DNS Made Easy zones: ${error.response?.data?.error?.[0] || error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to list DNS Made Easy zones"
});
}
};
export const listDNSMadeEasyRecords = async (
appConnection: TDNSMadeEasyConnection,
options: { zoneId: string; type?: string; name?: string }
): Promise<DNSMadeEasyApiResponse["data"]> => {
if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) {
throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" });
}
const {
credentials: { apiKey, secretKey }
} = appConnection;
const { zoneId, type, name } = options;
try {
const allRecords: DNSMadeEasyApiResponse["data"] = [];
let currentPage = 0;
let totalPages = 1;
// Fetch all pages of records
while (currentPage < totalPages) {
// Build query parameters
const queryParams: Record<string, string | number> = {};
if (type) {
queryParams.type = type;
}
if (name) {
queryParams.recordName = name;
}
queryParams.page = currentPage;
// eslint-disable-next-line no-await-in-loop
const resp = await request.get<DNSMadeEasyApiResponse>(
getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(zoneId)}/records`),
{
headers: {
...makeDNSMadeEasyAuthHeaders(apiKey, secretKey),
Accept: "application/json"
},
params: queryParams
}
);
if (resp.data?.data) {
allRecords.push(...resp.data.data);
// Update pagination info
totalPages = resp.data.totalPages || 1;
currentPage += 1;
} else {
break;
}
}
return allRecords;
} catch (error: unknown) {
logger.error(error, "Error listing DNS Made Easy records");
if (error instanceof AxiosError) {
throw new BadRequestError({
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
message: `Failed to list DNS Made Easy records: ${error.response?.data?.error?.[0] || error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to list DNS Made Easy records"
});
}
};
export const validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeEasyConnectionConfig) => {
if (config.method !== DNSMadeEasyConnectionMethod.APIKeySecret) {
throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" });
}
const { apiKey, secretKey } = config.credentials;
try {
const resp = await request.get(getDNSMadeEasyUrl("/V2.0/dns/managed/"), {
headers: {
...makeDNSMadeEasyAuthHeaders(apiKey, secretKey),
Accept: "application/json"
}
});
if (resp.status !== 200) {
throw new BadRequestError({
message: "Unable to validate connection: Invalid API credentials provided."
});
}
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
message: `Failed to validate credentials: ${error.response?.data?.error?.[0] || error.message || "Unknown error"}`
});
}
logger.error(error, "Error validating DNS Made Easy connection credentials");
throw new BadRequestError({
message: "Unable to validate connection: verify credentials"
});
}
return config.credentials;
};

View File

@@ -0,0 +1,64 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps";
import { DNSMadeEasyConnectionMethod } from "./dns-made-easy-connection-enum";
export const DNSMadeEasyConnectionApiKeyCredentialsSchema = z.object({
apiKey: z.string().trim().min(1, "API key required").max(256, "API key cannot exceed 256 characters"),
secretKey: z.string().trim().min(1, "Secret key required").max(256, "Secret key cannot exceed 256 characters")
});
const BaseDNSMadeEasyConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.DNSMadeEasy)
});
export const DNSMadeEasyConnectionSchema = BaseDNSMadeEasyConnectionSchema.extend({
method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret),
credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema
});
export const SanitizedDNSMadeEasyConnectionSchema = z.discriminatedUnion("method", [
BaseDNSMadeEasyConnectionSchema.extend({
method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret),
credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.pick({ apiKey: true })
}).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.DNSMadeEasy]} (API Key)` }))
]);
export const ValidateDNSMadeEasyConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(DNSMadeEasyConnectionMethod.APIKeySecret)
.describe(AppConnections.CREATE(AppConnection.DNSMadeEasy).method),
credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.DNSMadeEasy).credentials
)
})
]);
export const CreateDNSMadeEasyConnectionSchema = ValidateDNSMadeEasyConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.DNSMadeEasy)
);
export const UpdateDNSMadeEasyConnectionSchema = z
.object({
credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.DNSMadeEasy).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.DNSMadeEasy));
export const DNSMadeEasyConnectionListItemSchema = z
.object({
name: z.literal("DNS Made Easy"),
app: z.literal(AppConnection.DNSMadeEasy),
methods: z.nativeEnum(DNSMadeEasyConnectionMethod).array()
})
.describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.DNSMadeEasy] }));

View File

@@ -0,0 +1,35 @@
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { listDNSMadeEasyZones } from "./dns-made-easy-connection-fns";
import { TDNSMadeEasyConnection } from "./dns-made-easy-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TDNSMadeEasyConnection>;
export const dnsMadeEasyConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listZones = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.DNSMadeEasy, connectionId, actor);
try {
const zones = await listDNSMadeEasyZones(appConnection);
return zones;
} catch (error) {
logger.error(
error,
`Failed to list DNS Made Easy zones for DNS Made Easy connection [connectionId=${connectionId}]`
);
throw new BadRequestError({
message: `Failed to list DNS Made Easy zones: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
};
return {
listZones
};
};

View File

@@ -0,0 +1,30 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
CreateDNSMadeEasyConnectionSchema,
DNSMadeEasyConnectionSchema,
ValidateDNSMadeEasyConnectionCredentialsSchema
} from "./dns-made-easy-connection-schema";
export type TDNSMadeEasyConnection = z.infer<typeof DNSMadeEasyConnectionSchema>;
export type TDNSMadeEasyConnectionInput = z.infer<typeof CreateDNSMadeEasyConnectionSchema> & {
app: AppConnection.DNSMadeEasy;
};
export type TValidateDNSMadeEasyConnectionCredentialsSchema = typeof ValidateDNSMadeEasyConnectionCredentialsSchema;
export type TDNSMadeEasyConnectionConfig = DiscriminativePick<
TDNSMadeEasyConnectionInput,
"method" | "app" | "credentials"
> & {
orgId: string;
};
export type TDNSMadeEasyZone = {
id: string;
name: string;
};

View File

@@ -1,4 +1,5 @@
export enum AcmeDnsProvider {
Route53 = "route53",
Cloudflare = "cloudflare"
Cloudflare = "cloudflare",
DNSMadeEasy = "dns-made-easy"
}

View File

@@ -14,6 +14,7 @@ import { decryptAppConnection } from "@app/services/app-connection/app-connectio
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types";
import { TCloudflareConnection } from "@app/services/app-connection/cloudflare/cloudflare-connection-types";
import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-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";
@@ -43,6 +44,7 @@ import {
TUpdateAcmeCertificateAuthorityDTO
} from "./acme-certificate-authority-types";
import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare";
import { dnsMadeEasyDeleteTxtRecord, dnsMadeEasyInsertTxtRecord } from "./dns-providers/dns-made-easy";
import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54";
type TAcmeCertificateAuthorityFnsDeps = {
@@ -120,6 +122,22 @@ export const castDbEntryToAcmeCertificateAuthority = (
};
};
const getAcmeChallengeRecord = (
provider: AcmeDnsProvider,
identifierValue: string,
keyAuthorization: string
): { recordName: string; recordValue: string } => {
let recordName: string;
if (provider === AcmeDnsProvider.DNSMadeEasy) {
// For DNS Made Easy, we don't need to provide the domain name in the record name.
recordName = "_acme-challenge";
} else {
recordName = `_acme-challenge.${identifierValue}`; // e.g., "_acme-challenge.example.com"
}
const recordValue = `"${keyAuthorization}"`; // must be double quoted
return { recordName, recordValue };
};
export const orderCertificate = async (
{
caId,
@@ -241,8 +259,11 @@ export const orderCertificate = async (
throw new Error("Unsupported challenge type");
}
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
const recordValue = `"${keyAuthorization}"`; // must be double quoted
const { recordName, recordValue } = getAcmeChallengeRecord(
acmeCa.configuration.dnsProviderConfig.provider,
authz.identifier.value,
keyAuthorization
);
switch (acmeCa.configuration.dnsProviderConfig.provider) {
case AcmeDnsProvider.Route53: {
@@ -263,14 +284,26 @@ export const orderCertificate = async (
);
break;
}
case AcmeDnsProvider.DNSMadeEasy: {
await dnsMadeEasyInsertTxtRecord(
connection as TDNSMadeEasyConnection,
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
recordName,
recordValue
);
break;
}
default: {
throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`);
}
}
},
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
const { recordName, recordValue } = getAcmeChallengeRecord(
acmeCa.configuration.dnsProviderConfig.provider,
authz.identifier.value,
keyAuthorization
);
switch (acmeCa.configuration.dnsProviderConfig.provider) {
case AcmeDnsProvider.Route53: {
@@ -291,6 +324,15 @@ export const orderCertificate = async (
);
break;
}
case AcmeDnsProvider.DNSMadeEasy: {
await dnsMadeEasyDeleteTxtRecord(
connection as TDNSMadeEasyConnection,
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
recordName,
recordValue
);
break;
}
default: {
throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`);
}
@@ -413,6 +455,12 @@ export const AcmeCertificateAuthorityFns = ({
});
}
if (dnsProviderConfig.provider === AcmeDnsProvider.DNSMadeEasy && appConnection.app !== AppConnection.DNSMadeEasy) {
throw new BadRequestError({
message: `App connection with ID '${dnsAppConnectionId}' is not a DNS Made Easy connection`
});
}
// validates permission to connect
await appConnectionService.validateAppConnectionUsageById(
appConnection.app as AppConnection,
@@ -508,6 +556,15 @@ export const AcmeCertificateAuthorityFns = ({
});
}
if (
dnsProviderConfig.provider === AcmeDnsProvider.DNSMadeEasy &&
appConnection.app !== AppConnection.DNSMadeEasy
) {
throw new BadRequestError({
message: `App connection with ID '${dnsAppConnectionId}' is not a DNS Made Easy connection`
});
}
const ca = await certificateAuthorityDAL.findById(id);
if (!ca) {

View File

@@ -0,0 +1,106 @@
import axios from "axios";
import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger";
import {
getDNSMadeEasyUrl,
listDNSMadeEasyRecords,
makeDNSMadeEasyAuthHeaders
} from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-fns";
import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types";
export const dnsMadeEasyInsertTxtRecord = async (
connection: TDNSMadeEasyConnection,
hostedZoneId: string,
domain: string,
value: string
) => {
const {
credentials: { apiKey, secretKey }
} = connection;
logger.info({ hostedZoneId, domain, value }, "Inserting TXT record for DNS Made Easy");
try {
await request.post(
getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records`),
{
type: "TXT",
name: domain,
value,
ttl: 60
},
{
headers: {
...makeDNSMadeEasyAuthHeaders(apiKey, secretKey),
"Content-Type": "application/json",
Accept: "application/json"
}
}
);
} catch (error) {
if (axios.isAxiosError(error)) {
const errorMessage =
(error.response?.data as { error?: string[] | string })?.error?.[0] ||
(error.response?.data as { error?: string[] | string })?.error ||
error.message ||
"Unknown error";
if (error.status === 400 && error.message.includes("already exists")) {
logger.info({ domain, value }, `Record already exists for domain: ${domain} and value: ${value}`);
return;
}
throw new Error(typeof errorMessage === "string" ? errorMessage : String(errorMessage));
}
throw error;
}
};
export const dnsMadeEasyDeleteTxtRecord = async (
connection: TDNSMadeEasyConnection,
hostedZoneId: string,
domain: string,
value: string
) => {
const {
credentials: { apiKey, secretKey }
} = connection;
logger.info({ hostedZoneId, domain, value }, "Deleting TXT record for DNS Made Easy");
try {
const dnsRecords = await listDNSMadeEasyRecords(connection, { zoneId: hostedZoneId, type: "TXT", name: domain });
let foundRecord = false;
if (dnsRecords.length > 0) {
const recordToDelete = dnsRecords.find(
(record) => record.type === "TXT" && record.name === domain && record.value === value
);
if (recordToDelete) {
await request.delete(
getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records/${recordToDelete.id}`),
{
headers: {
...makeDNSMadeEasyAuthHeaders(apiKey, secretKey),
Accept: "application/json"
}
}
);
foundRecord = true;
}
}
if (!foundRecord) {
logger.warn({ hostedZoneId, domain, value }, "Record to delete not found");
}
} catch (error) {
if (axios.isAxiosError(error)) {
const errorMessage =
(error.response?.data as { error?: string[] | string })?.error?.[0] ||
(error.response?.data as { error?: string[] | string })?.error ||
error.message ||
"Unknown error";
throw new Error(typeof errorMessage === "string" ? errorMessage : String(errorMessage));
}
throw error;
}
};

View File

@@ -105,7 +105,9 @@ export enum IntegrationUrls {
GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform",
GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations",
CHEF_API_URL = "https://api.chef.io"
CHEF_API_URL = "https://api.chef.io",
DNS_MADE_EASY_API_URL = "https://api.dnsmadeeasy.com",
DNS_MADE_EASY_SANDBOX_API_URL = "https://api.sandbox.dnsmadeeasy.com"
}
export const getIntegrationOptions = async () => {

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 120 60" width="120" height="60" style="enable-background:new 0 0 120 60;" xml:space="preserve">
<style type="text/css">
.st0{fill:#808285;}
.st1{fill:url(#SVGID_1_);}
.st2{fill:#005B99;}
.st3{enable-background:new ;}
.st4{fill:#77787B;}
</style>
<g>
<path class="st0" d="M34.1,42.6h9.4v-3.2C41.5,41.2,38.2,42.3,34.1,42.6L34.1,42.6z"/>
<path class="st0" d="M17.3,40.1v2.5h11.9c-2.4-0.2-5-0.6-7.6-1.2C20.1,41,18.7,40.6,17.3,40.1L17.3,40.1z"/>
<radialGradient id="SVGID_1_" cx="-183.5882" cy="811.1324" r="47.498" gradientTransform="matrix(1 0 0 1 241.0864 -776.3726)" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#0095DA"/>
<stop offset="0.21" style="stop-color:#0095DA"/>
<stop offset="0.33" style="stop-color:#00ACE4"/>
<stop offset="0.9045" style="stop-color:#005093"/>
<stop offset="0.9335" style="stop-color:#005396"/>
<stop offset="0.954" style="stop-color:#005C9F"/>
<stop offset="0.9718" style="stop-color:#006BAE"/>
<stop offset="0.988" style="stop-color:#0080C4"/>
<stop offset="1" style="stop-color:#0095DA"/>
</radialGradient>
<path class="st1" d="M43.5,18.5H29.2C23,17.1,15.4,17.1,10,18.2c2.4-0.3,5.1-0.3,7.8,0.1c0.7,0.1,1.3,0.2,2,0.3l0,0
c2.9,0.5,5.7,1.2,8.1,2.2l0,0c0.3,0.1,0.5,0.2,0.8,0.3h0c0.1,0,0.2,0.1,0.3,0.1h0c0.1,0,0.2,0.1,0.3,0.1h0c0.1,0,0.2,0.1,0.2,0.1
l0,0c0.1,0,0.1,0.1,0.2,0.1l0,0c0.1,0,0.1,0.1,0.2,0.1l0,0c0.1,0,0.2,0.1,0.2,0.1l0,0l0,0h0c0.1,0,0.1,0.1,0.2,0.1l0,0
c0.1,0,0.1,0.1,0.2,0.1l0,0c0.1,0,0.1,0.1,0.2,0.1l0,0c0.1,0,0.1,0.1,0.2,0.1l0,0c0.1,0,0.1,0.1,0.2,0.1l0,0c0.1,0,0.1,0.1,0.2,0.1
l0.1,0c0.1,0,0.1,0.1,0.2,0.1l0.1,0c0.1,0,0.1,0.1,0.2,0.1l0.1,0c0.1,0,0.1,0.1,0.2,0.1l0.1,0c0.1,0,0.1,0.1,0.1,0.1l0.1,0.1
c0.1,0,0.1,0.1,0.1,0.1l0.1,0.1c0.1,0,0.1,0.1,0.1,0.1l0.1,0.1c0,0,0.1,0.1,0.1,0.1c3.2,2.2,5.1,4.9,5.1,7.6c0,3.1-2.6,5.7-6.8,7.2
c2.9-1.5,4.6-3.6,4.6-6.1c0-3.1-2.7-6.3-7-8.7c-0.1,0-0.1-0.1-0.2-0.1c-0.1,0-0.1-0.1-0.2-0.1c-0.1,0-0.1-0.1-0.2-0.1
c-0.1,0-0.1-0.1-0.2-0.1c-0.1,0-0.1-0.1-0.2-0.1c-0.1,0-0.1-0.1-0.2-0.1c-0.1,0-0.1-0.1-0.2-0.1l-0.2-0.1c-0.1,0-0.2-0.1-0.2-0.1
l-0.1-0.1c-0.1,0-0.1-0.1-0.2-0.1c-0.1,0-0.1-0.1-0.2-0.1c-0.1,0-0.1-0.1-0.2-0.1c-0.1,0-0.1-0.1-0.2-0.1c-0.1,0-0.1-0.1-0.2-0.1
L26,22c-0.1,0-0.2-0.1-0.3-0.1h0l-0.2-0.1l0,0c-0.1,0-0.2-0.1-0.3-0.1l-0.1,0c-0.1-0.1-0.2-0.1-0.4-0.1h0c-0.1,0-0.2-0.1-0.3-0.1h0
c-0.1-0.1-0.3-0.1-0.5-0.2l-0.1,0c-0.1-0.1-0.3-0.1-0.5-0.1h0c-0.3-0.1-0.5-0.2-0.8-0.3l0,0c-0.3-0.1-0.6-0.2-0.8-0.3l0,0
c-0.2-0.1-0.3-0.1-0.5-0.1l0,0c-0.7-0.2-1.5-0.4-2.3-0.5l0,0c-0.6-0.1-1.2-0.2-1.8-0.3v19.2c0.2,0.1,0.4,0.1,0.6,0.1
C30.3,42,41,39.4,41.8,33.2c0.4-3.3-2-6.9-6.1-10c3.3,1.9,6,4.1,7.8,6.4c1,1.4,1.7,2.7,2,4.1c-0.1-1.9-0.9-4.8-2-6.9L43.5,18.5
L43.5,18.5z"/>
<g>
<path class="st2" d="M67.5,26.9c0,1-0.2,2-0.7,2.9c-0.4,0.9-1,1.7-1.8,2.3c-0.8,0.7-1.7,1.2-2.8,1.6c-1.1,0.4-2.2,0.6-3.5,0.6H49
v-9h4.5v5.3h5.2c0.6,0,1.2-0.1,1.7-0.3c0.5-0.2,1-0.4,1.4-0.7c0.4-0.3,0.7-0.7,0.9-1.1c0.2-0.4,0.3-0.9,0.3-1.4
c0-0.5-0.1-1-0.3-1.4c-0.2-0.4-0.5-0.8-0.9-1.1c-0.4-0.3-0.8-0.6-1.4-0.7c-0.5-0.2-1.1-0.3-1.7-0.3H49l2.9-3.8h6.8
c1.3,0,2.4,0.2,3.5,0.5c1.1,0.3,2,0.8,2.8,1.5s1.4,1.4,1.8,2.3C67.2,24.9,67.5,25.8,67.5,26.9z"/>
<g>
<path class="st2" d="M82.8,21.4v6.8l-8.9-8c-0.4-0.3-0.7-0.5-1-0.6c-0.3-0.1-0.6-0.1-0.8-0.1c-0.3,0-0.6,0.1-0.9,0.1
c-0.3,0.1-0.6,0.3-0.8,0.5c-0.2,0.2-0.4,0.5-0.5,0.8c-0.1,0.3-0.2,0.8-0.2,1.2v12h4.1v-8.5l8.9,8c0.3,0.3,0.7,0.5,0.9,0.6
c0.3,0.1,0.6,0.1,0.8,0.1c0.3,0,0.6-0.1,0.9-0.1c0.3-0.1,0.6-0.3,0.8-0.5c0.2-0.2,0.4-0.5,0.5-0.8c0.1-0.3,0.2-0.8,0.2-1.2V19.9
L82.8,21.4z"/>
</g>
<path class="st2" d="M103,25.5c1.8,0,3.1,0.3,4,1c0.9,0.7,1.4,1.6,1.4,3c0,0.7-0.1,1.4-0.3,2c-0.2,0.6-0.6,1.1-1.1,1.5
c-0.5,0.4-1.2,0.7-1.9,0.9c-0.8,0.2-1.7,0.3-2.8,0.3H88.7l2.9-3.7h11c0.5,0,0.9-0.1,1.2-0.3c0.3-0.2,0.4-0.4,0.4-0.8
s-0.1-0.7-0.4-0.8c-0.3-0.2-0.6-0.2-1.2-0.2h-7.9c-0.9,0-1.8-0.1-2.4-0.3c-0.7-0.2-1.2-0.5-1.7-0.8c-0.5-0.4-0.8-0.8-1-1.3
c-0.2-0.5-0.3-1.1-0.3-1.7c0-0.7,0.1-1.3,0.4-1.9c0.2-0.6,0.6-1,1.1-1.4c0.5-0.4,1.1-0.7,1.9-0.9c0.8-0.2,1.7-0.3,2.8-0.3h12.6
l-2.9,3.8H95.1c-0.5,0-0.9,0.1-1.2,0.2c-0.3,0.1-0.4,0.4-0.4,0.8c0,0.4,0.1,0.6,0.4,0.8c0.3,0.1,0.6,0.2,1.2,0.2L103,25.5
L103,25.5z"/>
</g>
<g class="st3">
<path class="st4" d="M57.5,36.6v5h-1.4v-2.7v-0.7l0.1-0.4v-0.4h-0.1l-0.2,0.3l-0.2,0.3l-0.4,0.7l-1.7,2.8h-1.3l-1.7-2.8l-0.4-0.7
l-0.2-0.3l-0.2-0.3h-0.1l0,0.4v0.4l0.1,0.7v2.7h-1.5v-5h2.4l1.4,2.3l0.4,0.7l0.2,0.3l0.2,0.3H53l0.2-0.3l0.2-0.3l0.4-0.7l1.4-2.3
L57.5,36.6L57.5,36.6z"/>
<path class="st4" d="M63.6,40.6h-3.3l-0.5,0.9h-1.6l2.6-5H63l2.6,5H64L63.6,40.6z M63.2,39.9l-1.3-2.6l-1.3,2.6H63.2z"/>
<path class="st4" d="M66.2,41.6v-5H70c1,0,1.8,0.2,2.2,0.5s0.7,0.8,0.7,1.6c0,0.7,0,1.2-0.1,1.5c0,0.3-0.2,0.6-0.5,0.9
c-0.3,0.3-1,0.5-2.3,0.5H66.2L66.2,41.6z M67.7,40.7h2.1c0.7,0,1.2-0.1,1.4-0.3s0.3-0.7,0.3-1.4c0-0.7-0.1-1.2-0.3-1.4
s-0.6-0.3-1.3-0.3h-2.2L67.7,40.7L67.7,40.7z"/>
<path class="st4" d="M75.3,37.4v1.3h3.6v0.7h-3.6v1.4h3.8v0.8h-5.3v-5h5.3v0.8L75.3,37.4L75.3,37.4z"/>
<path class="st4" d="M84.4,37.4v1.3H88v0.7h-3.6v1.4h3.8v0.8H83v-5h5.3v0.8L84.4,37.4L84.4,37.4z"/>
<path class="st4" d="M94,40.6h-3.3l-0.5,0.9h-1.6l2.6-5h2.2l2.6,5h-1.5L94,40.6z M93.7,39.9l-1.3-2.6L91,39.9H93.7z"/>
<path class="st4" d="M102.4,38.1H101v-0.1c0-0.3-0.1-0.4-0.3-0.5c-0.2-0.1-0.6-0.1-1.2-0.1c-0.7,0-1.2,0-1.4,0.1
c-0.2,0.1-0.3,0.3-0.3,0.5c0,0.3,0.1,0.5,0.3,0.6s0.8,0.1,1.7,0.1c1.1,0,1.9,0.1,2.2,0.3c0.3,0.2,0.5,0.5,0.5,1.1
c0,0.7-0.2,1.1-0.6,1.3s-1.2,0.3-2.5,0.3c-1.3,0-2.2-0.1-2.5-0.3c-0.4-0.2-0.6-0.6-0.6-1.2V40h1.4v0.1c0,0.3,0.1,0.5,0.3,0.6
c0.2,0.1,0.7,0.1,1.5,0.1c0.7,0,1.1,0,1.2-0.1s0.3-0.3,0.3-0.6c0-0.3-0.1-0.4-0.2-0.5c-0.1-0.1-0.4-0.1-0.9-0.1l-0.8,0
c-1.2,0-2-0.1-2.3-0.3c-0.4-0.2-0.5-0.6-0.5-1.1c0-0.6,0.2-1,0.6-1.2c0.4-0.2,1.2-0.3,2.5-0.3c1.1,0,1.9,0.1,2.3,0.3
c0.4,0.2,0.6,0.5,0.6,1L102.4,38.1L102.4,38.1z"/>
<path class="st4" d="M110,36.6l-2.9,3.1v1.9h-1.5v-1.9l-2.8-3.1h1.7l1.2,1.3l0.3,0.4l0.2,0.2l0.2,0.2l0.2-0.2l0.2-0.2l0.3-0.4
l1.2-1.3H110z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@@ -57,6 +57,7 @@ import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-con
import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection";
import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection";
import { SupabaseConnectionMethod } from "@app/hooks/api/appConnections/types/supabase-connection";
import { DNSMadeEasyConnectionMethod } from "@app/hooks/api/appConnections/types/dns-made-easy-connection";
export const APP_CONNECTION_MAP: Record<
AppConnection,
@@ -111,6 +112,7 @@ export const APP_CONNECTION_MAP: Record<
[AppConnection.Flyio]: { name: "Fly.io", image: "Flyio.svg" },
[AppConnection.GitLab]: { name: "GitLab", image: "GitLab.png" },
[AppConnection.Cloudflare]: { name: "Cloudflare", image: "Cloudflare.png" },
[AppConnection.DNSMadeEasy]: { name: "DNS Made Easy", image: "DNSMadeEasy.svg", size: 120 },
[AppConnection.Zabbix]: { name: "Zabbix", image: "Zabbix.png" },
[AppConnection.Railway]: { name: "Railway", image: "Railway.png" },
[AppConnection.Bitbucket]: { name: "Bitbucket", image: "Bitbucket.png" },
@@ -214,6 +216,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
return { name: "Client Secret", icon: faKey };
case AzureClientSecretsConnectionMethod.Certificate:
return { name: "Certificate", icon: faCertificate };
case DNSMadeEasyConnectionMethod.APIKeySecret:
return { name: "API Key & Secret", icon: faKey };
default:
throw new Error(`Unhandled App Connection Method: ${method}`);
}

View File

@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./types";

View File

@@ -0,0 +1,37 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "../queries";
import { TDNSMadeEasyZone } from "./types";
const dnsMadeEasyConnectionKeys = {
all: [...appConnectionKeys.all, "dns-made-easy"] as const,
listZones: (connectionId: string) =>
[...dnsMadeEasyConnectionKeys.all, "zones", connectionId] as const
};
export const useDNSMadeEasyConnectionListZones = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TDNSMadeEasyZone[],
unknown,
TDNSMadeEasyZone[],
ReturnType<typeof dnsMadeEasyConnectionKeys.listZones>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: dnsMadeEasyConnectionKeys.listZones(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TDNSMadeEasyZone[]>(
`/api/v1/app-connections/dns-made-easy/${connectionId}/dns-made-easy-zones`
);
return data;
},
...options
});
};

View File

@@ -0,0 +1,4 @@
export type TDNSMadeEasyZone = {
id: string;
name: string;
};

View File

@@ -29,6 +29,7 @@ export enum AppConnection {
Flyio = "flyio",
GitLab = "gitlab",
Cloudflare = "cloudflare",
DNSMadeEasy = "dns-made-easy",
Bitbucket = "bitbucket",
Zabbix = "zabbix",
Railway = "railway",

View File

@@ -184,6 +184,10 @@ export type TRedisConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Redis;
};
export type TDNSMadeEasyConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.DNSMadeEasy;
};
export type TAppConnectionOption =
| TAwsConnectionOption
| TGitHubConnectionOption
@@ -225,7 +229,8 @@ export type TAppConnectionOption =
| TOktaConnectionOption
| TAzureAdCsConnectionOption
| TLaravelForgeConnectionOption
| TChefConnectionOption;
| TChefConnectionOption
| TDNSMadeEasyConnectionOption;
export type TAppConnectionOptionMap = {
[AppConnection.AWS]: TAwsConnectionOption;
@@ -257,6 +262,7 @@ export type TAppConnectionOptionMap = {
[AppConnection.Flyio]: TFlyioConnectionOption;
[AppConnection.GitLab]: TGitlabConnectionOption;
[AppConnection.Cloudflare]: TCloudflareConnectionOption;
[AppConnection.DNSMadeEasy]: TDNSMadeEasyConnectionOption;
[AppConnection.Bitbucket]: TBitbucketConnectionOption;
[AppConnection.Zabbix]: TZabbixConnectionOption;
[AppConnection.Railway]: TRailwayConnectionOption;

View File

@@ -0,0 +1,14 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum DNSMadeEasyConnectionMethod {
APIKeySecret = "api-key-secret"
}
export type TDNSMadeEasyConnection = TRootAppConnection & { app: AppConnection.DNSMadeEasy } & {
method: DNSMadeEasyConnectionMethod.APIKeySecret;
credentials: {
apiKey: string;
secretKey: string;
};
};

View File

@@ -15,6 +15,7 @@ import { TChefConnection } from "./chef-connection";
import { TCloudflareConnection } from "./cloudflare-connection";
import { TDatabricksConnection } from "./databricks-connection";
import { TDigitalOceanConnection } from "./digital-ocean";
import { TDNSMadeEasyConnection } from "./dns-made-easy-connection";
import { TFlyioConnection } from "./flyio-connection";
import { TGcpConnection } from "./gcp-connection";
import { TGitHubConnection } from "./github-connection";
@@ -56,6 +57,7 @@ export * from "./camunda-connection";
export * from "./checkly-connection";
export * from "./chef-connection";
export * from "./cloudflare-connection";
export * from "./dns-made-easy-connection";
export * from "./databricks-connection";
export * from "./flyio-connection";
export * from "./gcp-connection";
@@ -127,7 +129,8 @@ export type TAppConnection =
| TNorthflankConnection
| TOktaConnection
| TRedisConnection
| TChefConnection;
| TChefConnection
| TDNSMadeEasyConnection;
export type TAvailableAppConnection = Pick<TAppConnection, "name" | "id" | "projectId">;

View File

@@ -16,12 +16,14 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = {
export const ACME_DNS_PROVIDER_NAME_MAP: Record<AcmeDnsProvider, string> = {
[AcmeDnsProvider.ROUTE53]: "Route53",
[AcmeDnsProvider.Cloudflare]: "Cloudflare"
[AcmeDnsProvider.Cloudflare]: "Cloudflare",
[AcmeDnsProvider.DNSMadeEasy]: "DNS Made Easy"
};
export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record<AcmeDnsProvider, AppConnection> = {
[AcmeDnsProvider.ROUTE53]: AppConnection.AWS,
[AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare
[AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare,
[AcmeDnsProvider.DNSMadeEasy]: AppConnection.DNSMadeEasy
};
export const CA_TYPE_CAPABILITIES_MAP: Record<CaType, CaCapability[]> = {

View File

@@ -21,7 +21,8 @@ export enum CaRenewalType {
export enum AcmeDnsProvider {
ROUTE53 = "route53",
Cloudflare = "cloudflare"
Cloudflare = "cloudflare",
DNSMadeEasy = "dns-made-easy"
}
export enum CaCapability {

View File

@@ -27,6 +27,10 @@ import {
TCloudflareZone,
useCloudflareConnectionListZones
} from "@app/hooks/api/appConnections/cloudflare";
import {
TDNSMadeEasyZone,
useDNSMadeEasyConnectionListZones
} from "@app/hooks/api/appConnections/dns-made-easy";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
AcmeDnsProvider,
@@ -211,6 +215,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
enabled: caType === CaType.ACME
});
const { data: availableDNSMadeEasyConnections, isPending: isDNSMadeEasyPending } =
useListAvailableAppConnections(AppConnection.DNSMadeEasy, currentProject.id, {
enabled: caType === CaType.ACME
});
const { data: availableAzureConnections, isPending: isAzurePending } =
useListAvailableAppConnections(AppConnection.AzureADCS, currentProject.id, {
enabled: caType === CaType.AZURE_AD_CS
@@ -220,16 +229,24 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
if (caType === CaType.AZURE_AD_CS) {
return availableAzureConnections || [];
}
return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])];
return [
...(availableRoute53Connections || []),
...(availableCloudflareConnections || []),
...(availableDNSMadeEasyConnections || [])
];
}, [
caType,
availableRoute53Connections,
availableCloudflareConnections,
availableDNSMadeEasyConnections,
availableAzureConnections
]);
const isPending =
isRoute53Pending || isCloudflarePending || (isAzurePending && caType === CaType.AZURE_AD_CS);
isRoute53Pending ||
isCloudflarePending ||
isDNSMadeEasyPending ||
(isAzurePending && caType === CaType.AZURE_AD_CS);
const dnsAppConnection =
caType === CaType.ACME && configuration && "dnsAppConnection" in configuration
@@ -241,6 +258,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
enabled: dnsProvider === AcmeDnsProvider.Cloudflare && !!dnsAppConnection.id
});
const { data: dnsMadeEasyZones = [], isPending: isDNSMadeEasyZonesPending } =
useDNSMadeEasyConnectionListZones(dnsAppConnection.id, {
enabled: dnsProvider === AcmeDnsProvider.DNSMadeEasy && !!dnsAppConnection.id
});
// Populate form with CA data when editing
useEffect(() => {
if (ca && !isCaLoading) {
@@ -500,6 +522,32 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
)}
/>
)}
{dnsProvider === AcmeDnsProvider.DNSMadeEasy && (
<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={isDNSMadeEasyZonesPending && !!dnsAppConnection.id}
isDisabled={!dnsAppConnection.id}
value={dnsMadeEasyZones.find((zone) => zone.id === value)}
onChange={(option) => {
onChange((option as SingleValue<TDNSMadeEasyZone>)?.id ?? null);
}}
options={dnsMadeEasyZones}
placeholder="Select a zone..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
/>
)}
<Controller
control={control}
defaultValue=""

View File

@@ -24,6 +24,7 @@ import { ChefConnectionForm } from "./ChefConnectionForm";
import { CloudflareConnectionForm } from "./CloudflareConnectionForm";
import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { DigitalOceanConnectionForm } from "./DigitalOceanConnectionForm";
import { DNSMadeEasyConnectionForm } from "./DNSMadeEasyConnectionForm";
import { FlyioConnectionForm } from "./FlyioConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
@@ -148,6 +149,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => {
return <GitLabConnectionForm onSubmit={onSubmit} projectId={projectId} />;
case AppConnection.Cloudflare:
return <CloudflareConnectionForm onSubmit={onSubmit} />;
case AppConnection.DNSMadeEasy:
return <DNSMadeEasyConnectionForm onSubmit={onSubmit} />;
case AppConnection.Bitbucket:
return <BitbucketConnectionForm onSubmit={onSubmit} />;
case AppConnection.Zabbix:
@@ -306,6 +309,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
);
case AppConnection.Cloudflare:
return <CloudflareConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.DNSMadeEasy:
return <DNSMadeEasyConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Bitbucket:
return <BitbucketConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Zabbix:

View File

@@ -0,0 +1,157 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { TDNSMadeEasyConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { DNSMadeEasyConnectionMethod } from "@app/hooks/api/appConnections/types/dns-made-easy-connection";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TDNSMadeEasyConnection;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.DNSMadeEasy)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret),
credentials: z.object({
apiKey: z.string().trim().min(1, "API Key required"),
secretKey: z.string().trim().min(1, "Secret Key required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const DNSMadeEasyConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.DNSMadeEasy,
method: DNSMadeEasyConnectionMethod.APIKeySecret,
credentials: {
apiKey: "",
secretKey: ""
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.DNSMadeEasy].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(DNSMadeEasyConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.apiKey"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="API Key"
>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="af1b628f-3272-46aa-9cde-837d0c59155d"
/>
</FormControl>
)}
/>
<Controller
name="credentials.secretKey"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Secret Key"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:border-primary-400/50! border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to DNS Made Easy"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};