From 74d2d41961863989789cb2f2af67bb4a6b2e61bf Mon Sep 17 00:00:00 2001 From: outeiroDev Date: Sat, 22 Aug 2026 18:05:21 +0200 Subject: [PATCH 1/4] feat: add Porkbun DNS provider support Adds Porkbun as a supported DNS provider alongside Cloudflare and AWS Route53, allowing Dokploy to automatically create DNS records for domains managed on Porkbun. - New DnsClient implementation for the Porkbun API v3 - porkbun enum value and config schema (apiKey/secretApiKey) - Drizzle migration for the new DnsProviderType enum value - UI: provider icon, form fields and provider selector entry - Unit tests covering listZones/listRecords/upsertRecord/updateRecord/deleteRecord/testConnection --- apps/dokploy/__test__/dns/porkbun.test.ts | 211 + .../settings/dns/handle-dns-provider.tsx | 53 +- .../components/icons/dns-provider-icons.tsx | 20 + .../drizzle/0186_add_porkbun_dns_provider.sql | 1 + apps/dokploy/drizzle/meta/0186_snapshot.json | 9140 +++++++++++++++++ apps/dokploy/drizzle/meta/_journal.json | 7 + packages/server/src/db/schema/dns-provider.ts | 8 + packages/server/src/services/dns-provider.ts | 1 + packages/server/src/utils/dns/index.ts | 2 + packages/server/src/utils/dns/porkbun.ts | 134 + 10 files changed, 9576 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/dns/porkbun.test.ts create mode 100644 apps/dokploy/drizzle/0186_add_porkbun_dns_provider.sql create mode 100644 apps/dokploy/drizzle/meta/0186_snapshot.json create mode 100644 packages/server/src/utils/dns/porkbun.ts diff --git a/apps/dokploy/__test__/dns/porkbun.test.ts b/apps/dokploy/__test__/dns/porkbun.test.ts new file mode 100644 index 000000000..2275e9c5f --- /dev/null +++ b/apps/dokploy/__test__/dns/porkbun.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { porkbunClient } from "@dokploy/server/utils/dns/porkbun"; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +const pbSuccess = (result: Record = {}) => + jsonResponse({ status: "SUCCESS", ...result }); + +const pbError = (message: string, status = 400) => + jsonResponse({ status: "ERROR", message }, false, status); + +const config = { + providerType: "porkbun" as const, + apiKey: "pk1_test", + secretApiKey: "sk1_test", +}; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("porkbunClient.listZones", () => { + it("lists all domains as zones", async () => { + mockFetch.mockResolvedValue( + pbSuccess({ domains: [{ domain: "example.com" }] }), + ); + + const zones = await porkbunClient.listZones(config); + + expect(zones).toEqual([{ id: "example.com", name: "example.com" }]); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/domain/listAll"); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + apikey: "pk1_test", + secretapikey: "sk1_test", + }); + }); +}); + +describe("porkbunClient.listRecords", () => { + it("lists records for a domain", async () => { + mockFetch.mockResolvedValue( + pbSuccess({ + records: [ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: "600", + prio: "0", + notes: "", + }, + ], + }), + ); + + const records = await porkbunClient.listRecords(config, "example.com"); + + expect(records).toEqual([ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }, + ]); + expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com"); + }); +}); + +describe("porkbunClient.upsertRecord", () => { + it("creates a record when none exists for the name/type", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-1" })); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "new-1" }); + const [lookupUrl] = mockFetch.mock.calls[0] as [string]; + expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app"); + const [createUrl, createInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(createUrl).toContain("/dns/create/example.com"); + const body = JSON.parse(createInit.body as string); + expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" }); + }); + + it("resolves the apex domain to an empty subdomain", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-2" })); + + await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "example.com", + content: "1.2.3.4", + }); + + const [lookupUrl] = mockFetch.mock.calls[0] as [string]; + expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/"); + }); + + it("edits the existing record instead of creating a duplicate", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] })) + .mockResolvedValueOnce(pbSuccess({})); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "existing-1" }); + const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(editUrl).toContain("/dns/edit/example.com/existing-1"); + }); + + it("defaults ttl to 600 when not provided", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-1" })); + + await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "CNAME", + name: "www.example.com", + content: "example.com", + }); + + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(createInit.body as string); + expect(body.ttl).toBe(600); + }); +}); + +describe("porkbunClient.updateRecord", () => { + it("edits the given record id", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + + const result = await porkbunClient.updateRecord( + config, + "example.com", + "1", + { + type: "A", + name: "app.example.com", + content: "9.9.9.9", + ttl: 300, + }, + ); + + expect(result).toEqual({ id: "1" }); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/dns/edit/example.com/1"); + expect(JSON.parse(init.body as string)).toMatchObject({ + name: "app", + type: "A", + content: "9.9.9.9", + ttl: 300, + }); + }); +}); + +describe("porkbunClient.deleteRecord", () => { + it("posts to the delete endpoint for the given record id", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + + await porkbunClient.deleteRecord(config, "example.com", "1"); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("/dns/delete/example.com/1"); + }); +}); + +describe("porkbunClient.testConnection", () => { + it("succeeds when the credentials can ping the API", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined(); + }); + + it("surfaces Porkbun's error message on invalid credentials", async () => { + mockFetch.mockResolvedValue(pbError("Invalid API key.")); + + await expect(porkbunClient.testConnection(config)).rejects.toThrow( + "Invalid API key.", + ); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx index 67d6a7547..4a101e9a6 100644 --- a/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/dns/handle-dns-provider.tsx @@ -38,6 +38,7 @@ import { api } from "@/utils/api"; const providerLabels = { cloudflare: "Cloudflare", route53: "AWS Route53", + porkbun: "Porkbun", } as const; type ProviderType = keyof typeof providerLabels; @@ -49,10 +50,12 @@ const DnsProviderSchema = z.object({ .regex(/^[a-zA-Z0-9_-]+$/, { message: "Only letters, numbers, dashes and underscores", }), - providerType: z.enum(["cloudflare", "route53"]), + providerType: z.enum(["cloudflare", "route53", "porkbun"]), apiToken: z.string(), accessKeyId: z.string(), secretAccessKey: z.string(), + apiKey: z.string(), + secretApiKey: z.string(), }); type DnsProviderForm = z.infer; @@ -63,6 +66,8 @@ const defaultValues: DnsProviderForm = { apiToken: "", accessKeyId: "", secretAccessKey: "", + apiKey: "", + secretApiKey: "", }; const buildConfig = (data: DnsProviderForm) => { @@ -78,6 +83,12 @@ const buildConfig = (data: DnsProviderForm) => { accessKeyId: data.accessKeyId, secretAccessKey: data.secretAccessKey, }; + case "porkbun": + return { + providerType: "porkbun" as const, + apiKey: data.apiKey, + secretApiKey: data.secretApiKey, + }; } }; @@ -135,6 +146,10 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { accessKeyId: provider.config.accessKeyId, secretAccessKey: provider.config.secretAccessKey, }), + ...(provider.config.providerType === "porkbun" && { + apiKey: provider.config.apiKey, + secretApiKey: provider.config.secretApiKey, + }), }); } else if (!dnsProviderId) { form.reset(defaultValues); @@ -319,6 +334,42 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => { )} + {providerType === "porkbun" && ( + <> + ( + + API Key + + + + + + )} + /> + ( + + Secret API Key + + + + + Create API keys at porkbun.com/account/api and make sure + API access is enabled for the domains you want Dokploy + to manage. + + + + )} + /> + + )} +