diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index 26bfd84ad..f809b3686 100644 --- a/backend/bdd/features/pki/acme/external-ca.feature +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -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" - } + """ # 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" ] - """ \ No newline at end of file + """ + + 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 + """ + + """ + # 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"} | + | {} | diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 353ec942d..7e2d07476 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -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"] diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 591cac688..89ba17dd5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -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 diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 11de57667..21e83c2b7 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -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"), diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 5a3496750..48fdc7c38 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -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) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts new file mode 100644 index 000000000..e1e0b2860 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts @@ -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; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index aa1d671b6..d7a4065fd 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -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 { @@ -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, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 5b8cc3fc1..27d6a27a8 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -32,6 +32,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [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; + 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 => { + // 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 => { + 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(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 => { + 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 = {}; + 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( + 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; +}; diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts new file mode 100644 index 000000000..d968ba768 --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts @@ -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] })); diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts new file mode 100644 index 000000000..b50c9b73c --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts @@ -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; + +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 + }; +}; diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts new file mode 100644 index 000000000..eff96f6f9 --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts @@ -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; + +export type TDNSMadeEasyConnectionInput = z.infer & { + 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; +}; diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts index c4703d49f..09431f4f8 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts @@ -1,4 +1,5 @@ export enum AcmeDnsProvider { Route53 = "route53", - Cloudflare = "cloudflare" + Cloudflare = "cloudflare", + DNSMadeEasy = "dns-made-easy" } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index ff95083c6..db4340f4e 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -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) { diff --git a/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts b/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts new file mode 100644 index 000000000..cbfb26a2e --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts @@ -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; + } +}; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index e4e1d3126..b76e90470 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -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 () => { diff --git a/frontend/public/images/integrations/DNSMadeEasy.svg b/frontend/public/images/integrations/DNSMadeEasy.svg new file mode 100644 index 000000000..be77b9840 --- /dev/null +++ b/frontend/public/images/integrations/DNSMadeEasy.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 2266c05f9..20e959c42 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -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}`); } diff --git a/frontend/src/hooks/api/appConnections/dns-made-easy/index.ts b/frontend/src/hooks/api/appConnections/dns-made-easy/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/dns-made-easy/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/dns-made-easy/queries.tsx b/frontend/src/hooks/api/appConnections/dns-made-easy/queries.tsx new file mode 100644 index 000000000..6a5df54ac --- /dev/null +++ b/frontend/src/hooks/api/appConnections/dns-made-easy/queries.tsx @@ -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 + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: dnsMadeEasyConnectionKeys.listZones(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/dns-made-easy/${connectionId}/dns-made-easy-zones` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/dns-made-easy/types.ts b/frontend/src/hooks/api/appConnections/dns-made-easy/types.ts new file mode 100644 index 000000000..dcf66c1dd --- /dev/null +++ b/frontend/src/hooks/api/appConnections/dns-made-easy/types.ts @@ -0,0 +1,4 @@ +export type TDNSMadeEasyZone = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index fba0cbb4b..9535e8348 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -29,6 +29,7 @@ export enum AppConnection { Flyio = "flyio", GitLab = "gitlab", Cloudflare = "cloudflare", + DNSMadeEasy = "dns-made-easy", Bitbucket = "bitbucket", Zabbix = "zabbix", Railway = "railway", diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 1f553f605..d1c991f34 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -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; diff --git a/frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts b/frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts new file mode 100644 index 000000000..fd4dc098b --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts @@ -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; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 3e62031b3..dcefd549c 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -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; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 16a350dbd..740d52994 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -16,12 +16,14 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { export const ACME_DNS_PROVIDER_NAME_MAP: Record = { [AcmeDnsProvider.ROUTE53]: "Route53", - [AcmeDnsProvider.Cloudflare]: "Cloudflare" + [AcmeDnsProvider.Cloudflare]: "Cloudflare", + [AcmeDnsProvider.DNSMadeEasy]: "DNS Made Easy" }; export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record = { [AcmeDnsProvider.ROUTE53]: AppConnection.AWS, - [AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare + [AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare, + [AcmeDnsProvider.DNSMadeEasy]: AppConnection.DNSMadeEasy }; export const CA_TYPE_CAPABILITIES_MAP: Record = { diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index a68f3e862..acd8ce069 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -21,7 +21,8 @@ export enum CaRenewalType { export enum AcmeDnsProvider { ROUTE53 = "route53", - Cloudflare = "cloudflare" + Cloudflare = "cloudflare", + DNSMadeEasy = "dns-made-easy" } export enum CaCapability { diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index 1fb338a25..58aa2ba53 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -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 && ( + ( + + zone.id === value)} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + }} + options={dnsMadeEasyZones} + placeholder="Select a zone..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + )} { return ; case AppConnection.Cloudflare: return ; + case AppConnection.DNSMadeEasy: + return ; case AppConnection.Bitbucket: return ; case AppConnection.Zabbix: @@ -306,6 +309,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { ); case AppConnection.Cloudflare: return ; + case AppConnection.DNSMadeEasy: + return ; case AppConnection.Bitbucket: return ; case AppConnection.Zabbix: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx new file mode 100644 index 000000000..0d7d7403a --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx @@ -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; +}; + +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; + +export const DNSMadeEasyConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.DNSMadeEasy, + method: DNSMadeEasyConnectionMethod.APIKeySecret, + credentials: { + apiKey: "", + secretKey: "" + } + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + placeholder="af1b628f-3272-46aa-9cde-837d0c59155d" + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +};