From 126c3be60626e0b363b72c447ea4582799acadf5 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 19 Nov 2025 17:37:26 -0800 Subject: [PATCH 01/32] Add scaffolding for DNS made easy --- .../dns-made-easy-connection-router.ts | 52 +++++++++ .../routes/v1/app-connection-routers/index.ts | 2 + .../app-connection/app-connection-enums.ts | 1 + .../app-connection/app-connection-fns.ts | 8 ++ .../app-connection/app-connection-maps.ts | 2 + .../app-connection/app-connection-service.ts | 4 + .../dns-made-easy-connection-enum.ts | 3 + .../dns-made-easy-connection-fns.ts | 101 ++++++++++++++++++ .../dns-made-easy-connection-schema.ts | 64 +++++++++++ .../dns-made-easy-connection-service.ts | 32 ++++++ .../dns-made-easy-connection-types.ts | 24 +++++ 11 files changed, 293 insertions(+) create mode 100644 backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts create mode 100644 backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-enum.ts create mode 100644 backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts create mode 100644 backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts create mode 100644 backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts create mode 100644 backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts 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..398bd860e --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts @@ -0,0 +1,52 @@ +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 { getFlyioConnectionListItem(), getGitLabConnectionListItem(), getCloudflareConnectionListItem(), + getDNSMadeEasyConnectionListItem(), getZabbixConnectionListItem(), getRailwayConnectionListItem(), getBitbucketConnectionListItem(), @@ -339,6 +345,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, @@ -483,6 +490,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 { + return { + name: "DNS Made Easy" as const, + app: AppConnection.DNSMadeEasy as const, + methods: Object.values(DNSMadeEasyConnectionMethod) as [DNSMadeEasyConnectionMethod.APIKey] + }; +}; + +export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection): Promise => { + // TODO: Implement DNS Made Easy zones listing + // This should call the DNS Made Easy API to list all zones/domains + // Example API endpoint: GET https://api.dnsmadeeasy.com/V2.0/dns/managed + // Authentication: Use API key and secret from appConnection.credentials + // Return format: Array of { id: string, name: string } + + if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKey) { + throw new Error("Unsupported DNS Made Easy connection method"); + } + + const { + credentials: { apiKey, apiSecret } + } = appConnection; + + // TODO: Make API request to DNS Made Easy + // const { data } = await request.get<{ data: { id: number; name: string }[] }>( + // `${IntegrationUrls.DNS_MADE_EASY_API_URL}/V2.0/dns/managed`, + // { + // headers: { + // "x-dnsme-apiKey": apiKey, + // "x-dnsme-hmac": generateHMAC(apiSecret, ...), + // "x-dnsme-requestDate": requestDate, + // Accept: "application/json" + // } + // } + // ); + + // TODO: Transform response data to match TDNSMadeEasyZone format + // return data.data.map((zone) => ({ + // name: zone.name, + // id: zone.id.toString() + // })); + + throw new Error("Not implemented: listDNSMadeEasyZones"); +}; + +export const validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeEasyConnectionConfig) => { + // TODO: Implement DNS Made Easy credentials validation + // This should call the DNS Made Easy API to validate the API key and secret + // Example API endpoint: GET https://api.dnsmadeeasy.com/V2.0/account + // Authentication: Use API key and secret from config.credentials + + if (config.method !== DNSMadeEasyConnectionMethod.APIKey) { + throw new Error("Unsupported DNS Made Easy connection method"); + } + + const { apiKey, apiSecret } = config.credentials; + + try { + // TODO: Make API request to validate credentials + // const resp = await request.get(`${IntegrationUrls.DNS_MADE_EASY_API_URL}/V2.0/account`, { + // headers: { + // "x-dnsme-apiKey": apiKey, + // "x-dnsme-hmac": generateHMAC(apiSecret, ...), + // "x-dnsme-requestDate": requestDate, + // Accept: "application/json" + // } + // }); + // TODO: Validate response + // if (resp.data === null || !resp.data.id) { + // 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"}` + }); + } + 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..eb39b6235 --- /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"), + apiSecret: z.string().trim().min(1, "API secret required").max(256, "API secret cannot exceed 256 characters") +}); + +const BaseDNSMadeEasyConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.DNSMadeEasy) +}); + +export const DNSMadeEasyConnectionSchema = BaseDNSMadeEasyConnectionSchema.extend({ + method: z.literal(DNSMadeEasyConnectionMethod.APIKey), + credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema +}); + +export const SanitizedDNSMadeEasyConnectionSchema = z.discriminatedUnion("method", [ + BaseDNSMadeEasyConnectionSchema.extend({ + method: z.literal(DNSMadeEasyConnectionMethod.APIKey), + 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.APIKey) + .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..d5da33c93 --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts @@ -0,0 +1,32 @@ +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}]` + ); + return []; + } + }; + + 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..dabb72771 --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts @@ -0,0 +1,24 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { DNSMadeEasyConnectionSchema, CreateDNSMadeEasyConnectionSchema } from "./dns-made-easy-connection-schema"; + +export type TDNSMadeEasyConnection = z.infer; + +export type TDNSMadeEasyConnectionInput = z.infer & { + app: AppConnection.DNSMadeEasy; +}; + +export type TDNSMadeEasyConnectionConfig = DiscriminativePick< + TDNSMadeEasyConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TDNSMadeEasyZone = { + id: string; + name: string; +}; From 66ecddc58474dd189bcdb61fbe202aa03734f921 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 19 Nov 2025 18:14:32 -0800 Subject: [PATCH 02/32] Implement more stuff --- .../app-connection/app-connection-fns.ts | 14 ++- .../app-connection/app-connection-types.ts | 12 +- .../dns-made-easy-connection-enum.ts | 2 +- .../dns-made-easy-connection-fns.ts | 104 ++++++++++-------- .../dns-made-easy-connection-schema.ts | 6 +- .../dns-made-easy-connection-types.ts | 8 +- .../integration-auth/integration-list.ts | 4 +- 7 files changed, 94 insertions(+), 56 deletions(-) diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 57a219c71..5b03dc903 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -11,8 +11,8 @@ import { validateOCIConnectionCredentials } from "@app/ee/services/app-connections/oci"; import { getOracleDBConnectionListItem, OracleDBConnectionMethod } 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 { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { SECRET_ROTATION_CONNECTION_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; @@ -78,11 +78,6 @@ import { getCloudflareConnectionListItem, validateCloudflareConnectionCredentials } from "./cloudflare/cloudflare-connection-fns"; -import { DNSMadeEasyConnectionMethod } from "./dns-made-easy/dns-made-easy-connection-enum"; -import { - getDNSMadeEasyConnectionListItem, - validateDNSMadeEasyConnectionCredentials -} from "./dns-made-easy/dns-made-easy-connection-fns"; import { DatabricksConnectionMethod, getDatabricksConnectionListItem, @@ -93,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"; @@ -402,6 +402,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: diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index e1bdb8132..e26df87f5 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -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 diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-enum.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-enum.ts index 8ee08985f..6d782b4bb 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-enum.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-enum.ts @@ -1,3 +1,3 @@ export enum DNSMadeEasyConnectionMethod { - APIKey = "api-key" + APIKeySecret = "api-key-secret" } diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index 1d571224c..b50e47a03 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -1,6 +1,7 @@ import { AxiosError } from "axios"; import { request } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; @@ -12,11 +13,42 @@ import { TDNSMadeEasyZone } from "./dns-made-easy-connection-types"; +interface DNSMadeEasyApiResponse { + totalRecords: number; + totalPages: number; + data: Array<{ + id: number; + name: string; + [key: string]: unknown; + }>; + page: number; +} + +export const makeDNSMadeEasyAuthHeaders = ( + apiKey: string, + apiSecret: string, + currentDate: Date = new 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.toUTCString(); + + // Generate HMAC-SHA1 signature + const hmac = crypto.nativeCrypto.createHmac("sha1", apiSecret); + 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.APIKey] + methods: Object.values(DNSMadeEasyConnectionMethod) as [DNSMadeEasyConnectionMethod.APIKeySecret] }; }; @@ -27,7 +59,7 @@ export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection // Authentication: Use API key and secret from appConnection.credentials // Return format: Array of { id: string, name: string } - if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKey) { + if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { throw new Error("Unsupported DNS Made Easy connection method"); } @@ -35,56 +67,42 @@ export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection credentials: { apiKey, apiSecret } } = appConnection; - // TODO: Make API request to DNS Made Easy - // const { data } = await request.get<{ data: { id: number; name: string }[] }>( - // `${IntegrationUrls.DNS_MADE_EASY_API_URL}/V2.0/dns/managed`, - // { - // headers: { - // "x-dnsme-apiKey": apiKey, - // "x-dnsme-hmac": generateHMAC(apiSecret, ...), - // "x-dnsme-requestDate": requestDate, - // Accept: "application/json" - // } - // } - // ); - - // TODO: Transform response data to match TDNSMadeEasyZone format - // return data.data.map((zone) => ({ - // name: zone.name, - // id: zone.id.toString() - // })); - - throw new Error("Not implemented: listDNSMadeEasyZones"); + try { + // TODO: + const allZones: TDNSMadeEasyZone[] = []; + return allZones; + } catch (error: unknown) { + 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 validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeEasyConnectionConfig) => { - // TODO: Implement DNS Made Easy credentials validation - // This should call the DNS Made Easy API to validate the API key and secret - // Example API endpoint: GET https://api.dnsmadeeasy.com/V2.0/account - // Authentication: Use API key and secret from config.credentials - - if (config.method !== DNSMadeEasyConnectionMethod.APIKey) { + if (config.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { throw new Error("Unsupported DNS Made Easy connection method"); } const { apiKey, apiSecret } = config.credentials; try { - // TODO: Make API request to validate credentials - // const resp = await request.get(`${IntegrationUrls.DNS_MADE_EASY_API_URL}/V2.0/account`, { - // headers: { - // "x-dnsme-apiKey": apiKey, - // "x-dnsme-hmac": generateHMAC(apiSecret, ...), - // "x-dnsme-requestDate": requestDate, - // Accept: "application/json" - // } - // }); - // TODO: Validate response - // if (resp.data === null || !resp.data.id) { - // throw new BadRequestError({ - // message: "Unable to validate connection: Invalid API credentials provided." - // }); - // } + const resp = await request.get(`${IntegrationUrls.DNS_MADE_EASY_API_URL}/V2.0/dns/managed/`, { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, apiSecret), + 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({ 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 index eb39b6235..d5ed6d3cb 100644 --- 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 @@ -21,13 +21,13 @@ const BaseDNSMadeEasyConnectionSchema = BaseAppConnectionSchema.extend({ }); export const DNSMadeEasyConnectionSchema = BaseDNSMadeEasyConnectionSchema.extend({ - method: z.literal(DNSMadeEasyConnectionMethod.APIKey), + method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret), credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema }); export const SanitizedDNSMadeEasyConnectionSchema = z.discriminatedUnion("method", [ BaseDNSMadeEasyConnectionSchema.extend({ - method: z.literal(DNSMadeEasyConnectionMethod.APIKey), + method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret), credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.pick({ apiKey: true }) }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.DNSMadeEasy]} (API Key)` })) ]); @@ -35,7 +35,7 @@ export const SanitizedDNSMadeEasyConnectionSchema = z.discriminatedUnion("method export const ValidateDNSMadeEasyConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z - .literal(DNSMadeEasyConnectionMethod.APIKey) + .literal(DNSMadeEasyConnectionMethod.APIKeySecret) .describe(AppConnections.CREATE(AppConnection.DNSMadeEasy).method), credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.describe( AppConnections.CREATE(AppConnection.DNSMadeEasy).credentials 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 index dabb72771..eff96f6f9 100644 --- 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 @@ -3,7 +3,11 @@ import z from "zod"; import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { DNSMadeEasyConnectionSchema, CreateDNSMadeEasyConnectionSchema } from "./dns-made-easy-connection-schema"; +import { + CreateDNSMadeEasyConnectionSchema, + DNSMadeEasyConnectionSchema, + ValidateDNSMadeEasyConnectionCredentialsSchema +} from "./dns-made-easy-connection-schema"; export type TDNSMadeEasyConnection = z.infer; @@ -11,6 +15,8 @@ export type TDNSMadeEasyConnectionInput = z.infer { From 101470c4b37aab787e0bf7253b5a8fca016a93c8 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 19 Nov 2025 19:04:08 -0800 Subject: [PATCH 03/32] Add option --- .../app-connection-router.ts | 10 ++- .../app-connection/app-connection-fns.ts | 3 +- .../images/integrations/DNSMadeEasy.svg | 80 +++++++++++++++++++ frontend/src/helpers/appConnections.ts | 4 + .../src/hooks/api/appConnections/enums.ts | 1 + .../types/dns-made-easy-connection.ts | 14 ++++ .../hooks/api/appConnections/types/index.ts | 4 +- 7 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 frontend/public/images/integrations/DNSMadeEasy.svg create mode 100644 frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts 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/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 5b03dc903..a5d9c1ed8 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -223,7 +223,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getNorthflankConnectionListItem(), getOktaConnectionListItem(), getRedisConnectionListItem(), - getChefConnectionListItem() + getChefConnectionListItem(), + getDNSMadeEasyConnectionListItem() ] .filter((option) => { switch (projectType) { 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..d32f8cc7f 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" }, [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/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/dns-made-easy-connection.ts b/frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts new file mode 100644 index 000000000..2d1b93c86 --- /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: { + apiToken: string; + accountId: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 3e62031b3..f52c902d2 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"; @@ -127,7 +128,8 @@ export type TAppConnection = | TNorthflankConnection | TOktaConnection | TRedisConnection - | TChefConnection; + | TChefConnection + | TDNSMadeEasyConnection; export type TAvailableAppConnection = Pick; From b187cb9494ce9502d61dd43f106ad07c88b0b9bb Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 19 Nov 2025 21:36:17 -0800 Subject: [PATCH 04/32] Add dns made easy UI --- .../dns-made-easy-connection-fns.ts | 10 +- .../dns-made-easy-connection-schema.ts | 2 +- frontend/src/helpers/appConnections.ts | 2 +- .../types/dns-made-easy-connection.ts | 4 +- .../hooks/api/appConnections/types/index.ts | 1 + .../AppConnectionForm/AppConnectionForm.tsx | 5 + .../DNSMadeEasyConnectionForm.tsx | 157 ++++++++++++++++++ 7 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index b50e47a03..fc34940b8 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -26,14 +26,14 @@ interface DNSMadeEasyApiResponse { export const makeDNSMadeEasyAuthHeaders = ( apiKey: string, - apiSecret: string, + secretKey: string, currentDate: Date = new 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.toUTCString(); // Generate HMAC-SHA1 signature - const hmac = crypto.nativeCrypto.createHmac("sha1", apiSecret); + const hmac = crypto.nativeCrypto.createHmac("sha1", secretKey); hmac.update(requestDate); const hmacSignature = hmac.digest("hex"); @@ -64,7 +64,7 @@ export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection } const { - credentials: { apiKey, apiSecret } + credentials: { apiKey, secretKey } } = appConnection; try { @@ -89,12 +89,12 @@ export const validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeE throw new Error("Unsupported DNS Made Easy connection method"); } - const { apiKey, apiSecret } = config.credentials; + const { apiKey, secretKey } = config.credentials; try { const resp = await request.get(`${IntegrationUrls.DNS_MADE_EASY_API_URL}/V2.0/dns/managed/`, { headers: { - ...makeDNSMadeEasyAuthHeaders(apiKey, apiSecret), + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), Accept: "application/json" } }); 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 index d5ed6d3cb..d968ba768 100644 --- 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 @@ -13,7 +13,7 @@ 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"), - apiSecret: z.string().trim().min(1, "API secret required").max(256, "API secret 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({ diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index d32f8cc7f..20e959c42 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -112,7 +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" }, + [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" }, 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 index 2d1b93c86..fd4dc098b 100644 --- a/frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts @@ -8,7 +8,7 @@ export enum DNSMadeEasyConnectionMethod { export type TDNSMadeEasyConnection = TRootAppConnection & { app: AppConnection.DNSMadeEasy } & { method: DNSMadeEasyConnectionMethod.APIKeySecret; credentials: { - apiToken: string; - accountId: string; + 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 f52c902d2..dcefd549c 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -57,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"; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index aca33ffa2..af26a940d 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -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 ; 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)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; From 0d721686e6d5f964995b76d5f6a58b221788917f Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 20 Nov 2025 18:49:46 -0800 Subject: [PATCH 05/32] Add sandbox switch for dns made easy --- backend/src/lib/config/env.ts | 1 + .../dns-made-easy/dns-made-easy-connection-fns.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) 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/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index fc34940b8..40bbe945b 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -5,7 +5,9 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import https from "https"; +import { getConfig } from "@app/lib/config/env"; import { DNSMadeEasyConnectionMethod } from "./dns-made-easy-connection-enum"; import { TDNSMadeEasyConnection, @@ -24,6 +26,11 @@ interface DNSMadeEasyApiResponse { page: number; } +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, @@ -92,7 +99,11 @@ export const validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeE const { apiKey, secretKey } = config.credentials; try { - const resp = await request.get(`${IntegrationUrls.DNS_MADE_EASY_API_URL}/V2.0/dns/managed/`, { + const resp = await request.get(getDNSMadeEasyUrl("/V2.0/dns/managed/"), { + httpAgent: new https.Agent({ + minVersion: "TLSv1.2", + maxVersion: "TLSv1.2" + }), headers: { ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), Accept: "application/json" From dd0b0e4633c31c02b208b401044feaf74bc144fd Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 20 Nov 2025 20:05:49 -0800 Subject: [PATCH 06/32] No need to use https agent --- .../dns-made-easy/dns-made-easy-connection-fns.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index 40bbe945b..80ee0bee4 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -1,13 +1,12 @@ 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 { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; -import https from "https"; -import { getConfig } from "@app/lib/config/env"; import { DNSMadeEasyConnectionMethod } from "./dns-made-easy-connection-enum"; import { TDNSMadeEasyConnection, @@ -100,10 +99,6 @@ export const validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeE try { const resp = await request.get(getDNSMadeEasyUrl("/V2.0/dns/managed/"), { - httpAgent: new https.Agent({ - minVersion: "TLSv1.2", - maxVersion: "TLSv1.2" - }), headers: { ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), Accept: "application/json" From 551e9b4476220f95a346d8b47b4a3decd95aee95 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 20 Nov 2025 20:32:55 -0800 Subject: [PATCH 07/32] Add DNS made easy stuff --- .../dns-made-easy-connection-fns.ts | 2 + .../acme/acme-certificate-authority-enums.ts | 3 +- .../acme/acme-certificate-authority-fns.ts | 33 +++++++++++++ .../integration-auth/integration-list.ts | 4 +- frontend/src/hooks/api/ca/constants.tsx | 6 ++- frontend/src/hooks/api/ca/enums.tsx | 3 +- .../components/ExternalCaModal.tsx | 46 ++++++++++++++++++- 7 files changed, 90 insertions(+), 7 deletions(-) diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index 80ee0bee4..d42ee465e 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -4,6 +4,7 @@ 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"; @@ -116,6 +117,7 @@ export const validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeE 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" }); 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..00902b791 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 @@ -44,6 +44,7 @@ import { } from "./acme-certificate-authority-types"; import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare"; import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54"; +import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types"; type TAcmeCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; @@ -263,6 +264,15 @@ 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}`); } @@ -291,6 +301,14 @@ export const orderCertificate = async ( ); break; } + case AcmeDnsProvider.DNSMadeEasy: { + await dnsMadeEasyDeleteTxtRecord( + connection as TDNSMadeEasyConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + } default: { throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); } @@ -413,6 +431,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 +532,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/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 9f2ad0ee6..b76e90470 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -106,8 +106,8 @@ export enum IntegrationUrls { GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations", CHEF_API_URL = "https://api.chef.io", - DNS_MADE_EASY_API_URL = "https://api.dnsmadeeasy.com/V2.0", - DNS_MADE_EASY_SANDBOX_API_URL = "https://api.sandbox.dnsmadeeasy.com/V2.0" + 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/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..e24cc6881 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 @@ -225,11 +234,15 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { 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 +254,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 +518,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} + /> + + )} + /> + )} Date: Thu, 20 Nov 2025 20:45:19 -0800 Subject: [PATCH 08/32] Add more missing stuff --- .../acme/acme-certificate-authority-fns.ts | 4 +- .../acme/dns-providers/dns-made-easy.ts | 129 ++++++++++++++++++ .../api/appConnections/types/app-options.ts | 8 +- 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts 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 00902b791..8dbb9512f 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,8 +44,8 @@ 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"; -import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types"; type TAcmeCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; @@ -308,6 +309,7 @@ export const orderCertificate = async ( recordName, recordValue ); + break; } default: { throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); 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..899f4aaf1 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts @@ -0,0 +1,129 @@ +import axios from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +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}`; +}; + +const makeDNSMadeEasyAuthHeaders = ( + apiKey: string, + secretKey: string, + currentDate: Date = new 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.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 dnsMadeEasyInsertTxtRecord = async ( + connection: TDNSMadeEasyConnection, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiKey, secretKey } + } = connection; + + 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"; + 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; + + try { + // First, list records to find the record ID + const listRecordsResponse = await request.get<{ + data: Array<{ id: number; type: string; name: string; value: string }>; + }>(getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records`), { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + Accept: "application/json" + }, + params: { + type: "TXT", + recordName: domain + } + }); + + const dnsRecords = listRecordsResponse.data?.data; + + if (Array.isArray(dnsRecords) && 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" + } + } + ); + } + } + } 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/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; From 924d0298896086ee2b8a7ed5378502165063d18f Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 09:56:24 -0800 Subject: [PATCH 09/32] Implement listing zone --- .../dns-made-easy-connection-fns.ts | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index d42ee465e..40fda4fb2 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -60,12 +60,6 @@ export const getDNSMadeEasyConnectionListItem = () => { }; export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection): Promise => { - // TODO: Implement DNS Made Easy zones listing - // This should call the DNS Made Easy API to list all zones/domains - // Example API endpoint: GET https://api.dnsmadeeasy.com/V2.0/dns/managed - // Authentication: Use API key and secret from appConnection.credentials - // Return format: Array of { id: string, name: string } - if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { throw new Error("Unsupported DNS Made Easy connection method"); } @@ -75,10 +69,42 @@ export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection } = appConnection; try { - // TODO: 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 From b4e16fa1f4dc5fb449b59d1c8b65a04a3cc67ac9 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 10:19:26 -0800 Subject: [PATCH 10/32] Fix insert and del --- .../dns-made-easy-connection-router.ts | 1 - .../acme/dns-providers/dns-made-easy.ts | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 index 398bd860e..e1e0b2860 100644 --- 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 @@ -44,7 +44,6 @@ export const registerDNSMadeEasyConnectionRouter = async (server: FastifyZodProv 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/services/certificate-authority/acme/dns-providers/dns-made-easy.ts b/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts index 899f4aaf1..b3278e5d3 100644 --- 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 @@ -5,6 +5,7 @@ import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto/cryptography"; import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { logger } from "@app/lib/logger"; const getDNSMadeEasyUrl = (path: string) => { const appCfg = getConfig(); @@ -100,7 +101,7 @@ export const dnsMadeEasyDeleteTxtRecord = async ( if (Array.isArray(dnsRecords) && dnsRecords.length > 0) { const recordToDelete = dnsRecords.find( - (record) => record.type === "TXT" && record.name === domain && record.value === value + (record) => record.type === "TXT" && record.name === domain && JSON.parse(record.value) === value ); if (recordToDelete) { @@ -113,6 +114,8 @@ export const dnsMadeEasyDeleteTxtRecord = async ( } } ); + } else { + logger.warn({ domain, value }, `Record to delete not found for domain: ${domain} and value: ${value}`); } } } catch (error) { From 5aa52570736387a23dd7b4b21ae14b1c3ab5a9be Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 10:29:01 -0800 Subject: [PATCH 11/32] Ignore duplicate error --- .../acme/dns-providers/dns-made-easy.ts | 6 ++++++ 1 file changed, 6 insertions(+) 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 index b3278e5d3..ebd9105ae 100644 --- 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 @@ -66,6 +66,12 @@ export const dnsMadeEasyInsertTxtRecord = async ( (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; From 08d9c4748520ae4732e6d11a6c6b38e8256946b9 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 10:36:51 -0800 Subject: [PATCH 12/32] Add options --- .../components/ExternalCaModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index e24cc6881..58aa2ba53 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -229,7 +229,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { if (caType === CaType.AZURE_AD_CS) { return availableAzureConnections || []; } - return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])]; + return [ + ...(availableRoute53Connections || []), + ...(availableCloudflareConnections || []), + ...(availableDNSMadeEasyConnections || []) + ]; }, [ caType, availableRoute53Connections, From acca6f23237ab36ee1ca9c931f47ab2012b4f561 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 10:49:26 -0800 Subject: [PATCH 13/32] export --- .../dns-made-easy-connection-fns.ts | 2 +- .../acme/dns-providers/dns-made-easy.ts | 34 +++---------------- 2 files changed, 6 insertions(+), 30 deletions(-) diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index 40fda4fb2..2707f1637 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -26,7 +26,7 @@ interface DNSMadeEasyApiResponse { page: number; } -const getDNSMadeEasyUrl = (path: string) => { +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}`; }; 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 index ebd9105ae..2824e0005 100644 --- 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 @@ -1,36 +1,12 @@ import axios from "axios"; -import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; -import { crypto } from "@app/lib/crypto/cryptography"; -import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types"; -import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { logger } from "@app/lib/logger"; - -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}`; -}; - -const makeDNSMadeEasyAuthHeaders = ( - apiKey: string, - secretKey: string, - currentDate: Date = new 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.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 - }; -}; +import { + getDNSMadeEasyUrl, + 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, From 8c59579e1e57924be9690e81f41a0c557fef254b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 11:40:31 -0800 Subject: [PATCH 14/32] Fix wrong record name for dnsme --- .../acme/acme-certificate-authority-fns.ts | 6 +++++- .../acme/dns-providers/dns-made-easy.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) 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 8dbb9512f..445a2f032 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 @@ -243,7 +243,11 @@ export const orderCertificate = async ( throw new Error("Unsupported challenge type"); } - const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" + let recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" + if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.DNSMadeEasy) { + // For DNS Made Easy, we don't need to provide the domain name in the record name. + recordName = "_acme-challenge"; + } const recordValue = `"${keyAuthorization}"`; // must be double quoted switch (acmeCa.configuration.dnsProviderConfig.provider) { 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 index 2824e0005..87c21e264 100644 --- 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 @@ -18,6 +18,7 @@ export const dnsMadeEasyInsertTxtRecord = async ( 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`), @@ -64,6 +65,7 @@ export const dnsMadeEasyDeleteTxtRecord = async ( credentials: { apiKey, secretKey } } = connection; + logger.info({ hostedZoneId, domain, value }, "Deleting TXT record for DNS Made Easy"); try { // First, list records to find the record ID const listRecordsResponse = await request.get<{ From 6b4d5c95f3bace48dc0ba5ace4df82f1d045a96c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 12:10:30 -0800 Subject: [PATCH 15/32] Add DNS made easy for PKI type --- backend/src/services/app-connection/app-connection-fns.ts | 6 +++--- .../acme/dns-providers/dns-made-easy.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index a5d9c1ed8..145c6c3ed 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -176,7 +176,8 @@ const PKI_APP_CONNECTIONS = [ AppConnection.Cloudflare, AppConnection.AzureADCS, AppConnection.AzureKeyVault, - AppConnection.Chef + AppConnection.Chef, + AppConnection.DNSMadeEasy ]; export const listAppConnectionOptions = (projectType?: ProjectType) => { @@ -223,8 +224,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getNorthflankConnectionListItem(), getOktaConnectionListItem(), getRedisConnectionListItem(), - getChefConnectionListItem(), - getDNSMadeEasyConnectionListItem() + getChefConnectionListItem() ] .filter((option) => { switch (projectType) { 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 index 87c21e264..7dd405a5a 100644 --- 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 @@ -18,7 +18,7 @@ export const dnsMadeEasyInsertTxtRecord = async ( credentials: { apiKey, secretKey } } = connection; - logger.info({ hostedZoneId, domain, value }, "Inserting TXT record for DNS Made Easy"); + logger.info({ hostedZoneId, domain }, "Inserting TXT record for DNS Made Easy"); try { await request.post( getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records`), @@ -65,7 +65,7 @@ export const dnsMadeEasyDeleteTxtRecord = async ( credentials: { apiKey, secretKey } } = connection; - logger.info({ hostedZoneId, domain, value }, "Deleting TXT record for DNS Made Easy"); + logger.info({ hostedZoneId, domain }, "Deleting TXT record for DNS Made Easy"); try { // First, list records to find the record ID const listRecordsResponse = await request.get<{ From 4722f1f1f357a51042c80881ab2bd2a83939178e Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 15:41:54 -0800 Subject: [PATCH 16/32] Import order --- backend/src/services/app-connection/app-connection-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 145c6c3ed..d8af3773b 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -11,8 +11,8 @@ import { validateOCIConnectionCredentials } from "@app/ee/services/app-connections/oci"; import { getOracleDBConnectionListItem, OracleDBConnectionMethod } from "@app/ee/services/app-connections/oracledb"; -import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { SECRET_ROTATION_CONNECTION_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; import { SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-maps"; From 9ba677ff2bf6b34438896297638f1712e366223d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 15:45:09 -0800 Subject: [PATCH 17/32] Add missing dns made easy stuff --- .../api/appConnections/dns-made-easy/index.ts | 2 + .../appConnections/dns-made-easy/queries.tsx | 37 +++++++++++++++++++ .../api/appConnections/dns-made-easy/types.ts | 4 ++ 3 files changed, 43 insertions(+) create mode 100644 frontend/src/hooks/api/appConnections/dns-made-easy/index.ts create mode 100644 frontend/src/hooks/api/appConnections/dns-made-easy/queries.tsx create mode 100644 frontend/src/hooks/api/appConnections/dns-made-easy/types.ts 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; +}; From a5554c02d579a861fb17449704b06034d3915995 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 16:48:57 -0800 Subject: [PATCH 18/32] Add dns made easy test --- .../bdd/features/pki/acme/external-ca.feature | 183 +++++++++++++++++- backend/bdd/features/steps/pki_acme.py | 34 ++++ 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index 26bfd84ad..8007e8d4f 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: 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 @@ -172,6 +173,186 @@ Feature: External CA 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 # Note: somehow Pebble is issuing a cert without common name but just SANs + And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json + """ + [ + "localhost" + ] + """ + + @dnsme + Scenario: 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 + """ + { + "COMMON_NAME": "localhost" + } + """ + # 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", + "status": 200, + "response": { + "totalRecords": 1, + "totalPages": 1, + "data": [ + { + "processMulti": false, + "activeThirdParties": [], + "folderId": 2702, + "pendingActionId": 0, + "gtdEnabled": false, + "updated": 1763767936141, + "created": 1763596800000, + "name": "localhost", + "id": 1111111 + } + ], + "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 """ [ diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 353ec942d..8a2f433f9 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-token", + "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"] From 5badff83500e99dfd1e1699e44b0af698ed2946b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 17:11:49 -0800 Subject: [PATCH 19/32] Create with api-key-secret instead --- backend/bdd/features/steps/pki_acme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 8a2f433f9..7e2d07476 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -170,7 +170,7 @@ def step_impl(context: Context, var_name: str): json={ "name": conn_slug, "description": "", - "method": "api-token", + "method": "api-key-secret", "credentials": { "apiKey": "MOCK_API_KEY", "secretKey": "MOCK_SECRET_KEY", From 81aa1b1a3870340ec43db60c5411b313a616c12d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 18:04:49 -0800 Subject: [PATCH 20/32] Add listDNSMadeEasyRecords --- .../dns-made-easy-connection-fns.ts | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index 2707f1637..fb2be9a8a 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -117,6 +117,74 @@ export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection } }; +export const listDNSMadeEasyRecords = async ( + appConnection: TDNSMadeEasyConnection, + options: { zoneId: string; type?: string; name?: string } +): Promise => { + if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { + throw new Error("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: Array<[string, string | number]> = []; + if (type) { + queryParams.push(["type", type]); + } + if (name) { + queryParams.push(["recordName", name]); + } + queryParams.push(["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 Error("Unsupported DNS Made Easy connection method"); From b553edc80012925dcfcc6ffc19013b3f368ba9e9 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 18:36:02 -0800 Subject: [PATCH 21/32] Fix records query --- .../dns-made-easy-connection-fns.ts | 12 ++++++------ .../acme/dns-providers/dns-made-easy.ts | 17 ++--------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index fb2be9a8a..850d5ac36 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -21,7 +21,8 @@ interface DNSMadeEasyApiResponse { data: Array<{ id: number; name: string; - [key: string]: unknown; + type: string; + value: string; }>; page: number; } @@ -124,7 +125,6 @@ export const listDNSMadeEasyRecords = async ( if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { throw new Error("Unsupported DNS Made Easy connection method"); } - const { credentials: { apiKey, secretKey } } = appConnection; @@ -138,14 +138,14 @@ export const listDNSMadeEasyRecords = async ( // Fetch all pages of records while (currentPage < totalPages) { // Build query parameters - const queryParams: Array<[string, string | number]> = []; + const queryParams: Record = {}; if (type) { - queryParams.push(["type", type]); + queryParams.type = type; } if (name) { - queryParams.push(["recordName", name]); + queryParams.recordName = name; } - queryParams.push(["page", currentPage]); + queryParams.page = currentPage; // eslint-disable-next-line no-await-in-loop const resp = await request.get( 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 index 7dd405a5a..9356f8c42 100644 --- 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 @@ -4,6 +4,7 @@ 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"; @@ -67,21 +68,7 @@ export const dnsMadeEasyDeleteTxtRecord = async ( logger.info({ hostedZoneId, domain }, "Deleting TXT record for DNS Made Easy"); try { - // First, list records to find the record ID - const listRecordsResponse = await request.get<{ - data: Array<{ id: number; type: string; name: string; value: string }>; - }>(getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records`), { - headers: { - ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), - Accept: "application/json" - }, - params: { - type: "TXT", - recordName: domain - } - }); - - const dnsRecords = listRecordsResponse.data?.data; + const dnsRecords = await listDNSMadeEasyRecords(connection, { zoneId: hostedZoneId, type: "TXT", name: domain }); if (Array.isArray(dnsRecords) && dnsRecords.length > 0) { const recordToDelete = dnsRecords.find( From 435ef4c5dc4fcacfcfa89eee9b750836fc20034a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 18:55:57 -0800 Subject: [PATCH 22/32] Better logs --- .../acme/dns-providers/dns-made-easy.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 index 9356f8c42..6744662f1 100644 --- 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 @@ -66,11 +66,12 @@ export const dnsMadeEasyDeleteTxtRecord = async ( credentials: { apiKey, secretKey } } = connection; - logger.info({ hostedZoneId, domain }, "Deleting TXT record for DNS Made Easy"); + logger.info({ zoneId: hostedZoneId, domain }, "Deleting TXT record for DNS Made Easy"); try { const dnsRecords = await listDNSMadeEasyRecords(connection, { zoneId: hostedZoneId, type: "TXT", name: domain }); - if (Array.isArray(dnsRecords) && dnsRecords.length > 0) { + let foundRecord = false; + if (dnsRecords.length > 0) { const recordToDelete = dnsRecords.find( (record) => record.type === "TXT" && record.name === domain && JSON.parse(record.value) === value ); @@ -85,10 +86,12 @@ export const dnsMadeEasyDeleteTxtRecord = async ( } } ); - } else { - logger.warn({ domain, value }, `Record to delete not found for domain: ${domain} and value: ${value}`); + foundRecord = true; } } + if (!foundRecord) { + logger.warn({ zoneId: hostedZoneId, domain, value }, "Record to delete not found"); + } } catch (error) { if (axios.isAxiosError(error)) { const errorMessage = From f2cc6588c371b2b5485c95789ddbf78113c29650 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 19:05:39 -0800 Subject: [PATCH 23/32] Log, add query for nock --- backend/bdd/features/pki/acme/external-ca.feature | 1 + .../acme/dns-providers/dns-made-easy.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index 8007e8d4f..8fcdb9a72 100644 --- a/backend/bdd/features/pki/acme/external-ca.feature +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -319,6 +319,7 @@ Feature: External CA "scope": "https://api.dnsmadeeasy.com:443", "method": "GET", "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records", + "query": {"type": "TXT", "recordName": "_acme-challenge"}, "status": 200, "response": { "totalRecords": 1, 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 index 6744662f1..7fda6561d 100644 --- 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 @@ -19,7 +19,7 @@ export const dnsMadeEasyInsertTxtRecord = async ( credentials: { apiKey, secretKey } } = connection; - logger.info({ hostedZoneId, domain }, "Inserting TXT record for DNS Made Easy"); + logger.info({ hostedZoneId, domain, value }, "Inserting TXT record for DNS Made Easy"); try { await request.post( getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records`), @@ -66,7 +66,7 @@ export const dnsMadeEasyDeleteTxtRecord = async ( credentials: { apiKey, secretKey } } = connection; - logger.info({ zoneId: hostedZoneId, domain }, "Deleting TXT record for DNS Made Easy"); + logger.info({ hostedZoneId, domain, value }, "Deleting TXT record for DNS Made Easy"); try { const dnsRecords = await listDNSMadeEasyRecords(connection, { zoneId: hostedZoneId, type: "TXT", name: domain }); @@ -90,7 +90,7 @@ export const dnsMadeEasyDeleteTxtRecord = async ( } } if (!foundRecord) { - logger.warn({ zoneId: hostedZoneId, domain, value }, "Record to delete not found"); + logger.warn({ hostedZoneId, domain, value }, "Record to delete not found"); } } catch (error) { if (axios.isAxiosError(error)) { From 46ca9f0ff46f886d352aeed40c4b08cd39c4ed76 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 19:22:57 -0800 Subject: [PATCH 24/32] Try to fix query --- backend/bdd/features/pki/acme/external-ca.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index 8fcdb9a72..db68c6134 100644 --- a/backend/bdd/features/pki/acme/external-ca.feature +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -319,7 +319,7 @@ Feature: External CA "scope": "https://api.dnsmadeeasy.com:443", "method": "GET", "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records", - "query": {"type": "TXT", "recordName": "_acme-challenge"}, + "query": {"type": "TXT", "recordName": "_acme-challenge", "page": "0"}, "status": 200, "response": { "totalRecords": 1, From f7f8fd2595af81e2a8d78342a1017bdce7472adc Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 19:31:24 -0800 Subject: [PATCH 25/32] Fix path mock --- .../bdd/features/pki/acme/external-ca.feature | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index db68c6134..e46acca3a 100644 --- a/backend/bdd/features/pki/acme/external-ca.feature +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -318,23 +318,26 @@ Feature: External CA { "scope": "https://api.dnsmadeeasy.com:443", "method": "GET", - "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records", - "query": {"type": "TXT", "recordName": "_acme-challenge", "page": "0"}, + "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records?type=TXT&recordName=_acme-challenge&page=0", "status": 200, "response": { "totalRecords": 1, "totalPages": 1, "data": [ { - "processMulti": false, - "activeThirdParties": [], - "folderId": 2702, - "pendingActionId": 0, - "gtdEnabled": false, - "updated": 1763767936141, - "created": 1763596800000, - "name": "localhost", - "id": 1111111 + "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 From 13022adc5ec07f1974e336d50201d3ae3cd60660 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 21:18:35 -0800 Subject: [PATCH 26/32] Try to see if we can avoid timestamp issue --- .../dns-made-easy/dns-made-easy-connection-fns.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index 850d5ac36..b66d6eb9c 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -35,10 +35,10 @@ export const getDNSMadeEasyUrl = (path: string) => { export const makeDNSMadeEasyAuthHeaders = ( apiKey: string, secretKey: string, - currentDate: Date = new Date() + 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.toUTCString(); + const requestDate = (currentDate ?? new Date()).toUTCString(); // Generate HMAC-SHA1 signature const hmac = crypto.nativeCrypto.createHmac("sha1", secretKey); From 606a5848f26eca17839755f4eb8ab1b94dd212ec Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 21:42:01 -0800 Subject: [PATCH 27/32] Add external ca cases for absent CN --- .../bdd/features/pki/acme/external-ca.feature | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index e46acca3a..f809b3686 100644 --- a/backend/bdd/features/pki/acme/external-ca.feature +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -1,7 +1,7 @@ Feature: External CA @cloudflare - Scenario: Issue a certificate from an external CA with 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 @@ -93,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 @@ -180,8 +178,13 @@ Feature: External CA ] """ + Examples: + | subject | + | {"COMMON_NAME": "localhost"} | + | {} | + @dnsme - Scenario: Issue a certificate from an external CA with DNS Made Easy + 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 @@ -273,9 +276,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 @@ -362,4 +363,9 @@ Feature: External CA [ "localhost" ] - """ \ No newline at end of file + """ + + Examples: + | subject | + | {"COMMON_NAME": "localhost"} | + | {} | From 4776de1ba7916cd448cfd6aa45e4674fe3ef37b9 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 21 Nov 2025 21:42:24 -0800 Subject: [PATCH 28/32] Fix CN missing issue for external CA --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 9266dd10606e10f34e3555e5e2dab304fc2aea2e Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 24 Nov 2025 10:14:26 -0800 Subject: [PATCH 29/32] Extract getAcmeChallengeRecord --- .../acme/acme-certificate-authority-fns.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) 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 445a2f032..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 @@ -122,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, @@ -243,12 +259,11 @@ export const orderCertificate = async ( throw new Error("Unsupported challenge type"); } - let recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" - if (acmeCa.configuration.dnsProviderConfig.provider === AcmeDnsProvider.DNSMadeEasy) { - // For DNS Made Easy, we don't need to provide the domain name in the record name. - recordName = "_acme-challenge"; - } - 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: { @@ -284,8 +299,11 @@ export const orderCertificate = async ( } }, 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: { From 928aa07efa16c79a89d0452cb35070b893c7f0e2 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 25 Nov 2025 08:57:29 -0800 Subject: [PATCH 30/32] Throw bad req error instead --- .../dns-made-easy/dns-made-easy-connection-fns.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts index b66d6eb9c..9d1941ff3 100644 --- a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-fns.ts @@ -62,7 +62,7 @@ export const getDNSMadeEasyConnectionListItem = () => { export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection): Promise => { if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { - throw new Error("Unsupported DNS Made Easy connection method"); + throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" }); } const { @@ -123,7 +123,7 @@ export const listDNSMadeEasyRecords = async ( options: { zoneId: string; type?: string; name?: string } ): Promise => { if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { - throw new Error("Unsupported DNS Made Easy connection method"); + throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" }); } const { credentials: { apiKey, secretKey } @@ -187,7 +187,7 @@ export const listDNSMadeEasyRecords = async ( export const validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeEasyConnectionConfig) => { if (config.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { - throw new Error("Unsupported DNS Made Easy connection method"); + throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" }); } const { apiKey, secretKey } = config.credentials; From c25573d364d2d5d0c15de577c4fe3ccf459b0d72 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 25 Nov 2025 09:06:59 -0800 Subject: [PATCH 31/32] Throw error for listing --- .../dns-made-easy/dns-made-easy-connection-service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index d5da33c93..b50c9b73c 100644 --- 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 @@ -1,3 +1,4 @@ +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; @@ -22,7 +23,9 @@ export const dnsMadeEasyConnectionService = (getAppConnection: TGetAppConnection error, `Failed to list DNS Made Easy zones for DNS Made Easy connection [connectionId=${connectionId}]` ); - return []; + throw new BadRequestError({ + message: `Failed to list DNS Made Easy zones: ${error instanceof Error ? error.message : "Unknown error"}` + }); } }; From aca7facaebd7de84205ff31daf7cc4a1dba86a2b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 25 Nov 2025 09:37:26 -0800 Subject: [PATCH 32/32] Fix DNS record not deleted bug --- .../certificate-authority/acme/dns-providers/dns-made-easy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7fda6561d..cbfb26a2e 100644 --- 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 @@ -73,7 +73,7 @@ export const dnsMadeEasyDeleteTxtRecord = async ( let foundRecord = false; if (dnsRecords.length > 0) { const recordToDelete = dnsRecords.find( - (record) => record.type === "TXT" && record.name === domain && JSON.parse(record.value) === value + (record) => record.type === "TXT" && record.name === domain && record.value === value ); if (recordToDelete) {