mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
Merge branch 'canary' into feat/dns-records-management
This commit is contained in:
commit
8cd2ba80f2
211
apps/dokploy/__test__/dns/porkbun.test.ts
Normal file
211
apps/dokploy/__test__/dns/porkbun.test.ts
Normal file
@ -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<string, unknown> = {}) =>
|
||||
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -43,6 +43,7 @@ import { api } from "@/utils/api";
|
||||
const providerLabels = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
porkbun: "Porkbun",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
@ -54,10 +55,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<typeof DnsProviderSchema>;
|
||||
@ -68,6 +71,8 @@ const defaultValues: DnsProviderForm = {
|
||||
apiToken: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
apiKey: "",
|
||||
secretApiKey: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
@ -83,6 +88,12 @@ const buildConfig = (data: DnsProviderForm) => {
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
};
|
||||
case "porkbun":
|
||||
return {
|
||||
providerType: "porkbun" as const,
|
||||
apiKey: data.apiKey,
|
||||
secretApiKey: data.secretApiKey,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@ -140,6 +151,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);
|
||||
@ -332,6 +347,42 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "porkbun" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretApiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Create API keys at porkbun.com/account/api and make sure
|
||||
API access is enabled for the domains you want Dokploy
|
||||
to manage.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -34,7 +34,65 @@ export const Route53Icon = ({ className }: Props) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PorkbunIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 129.3 114.3"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="#ED7778"
|
||||
d="M76,23.6c-18.7,0-33.8,15.1-33.8,33.8S57.3,91.3,76,91.3s33.8-15.1,33.8-33.8S94.7,23.6,76,23.6z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M67.1,43.4c-2.6-1.4-5.5-2.5-8.5-3.2c-0.6,1.3-0.9,2.6-0.9,4.1c0,2.2,0.7,4.2,1.9,5.8 C61.5,47.3,64,44.9,67.1,43.4z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M92.4,50.1c1.2-1.6,1.9-3.6,1.9-5.8c0-1.5-0.3-2.9-0.9-4.1c-3,0.6-5.9,1.7-8.5,3.2 C87.9,44.9,90.5,47.3,92.4,50.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M80.5,54.7c-0.6,0-1.1,0.5-1.1,1.1c0,0.2,0.1,0.4,0.2,0.6l0,0c0.4,0.6,1,1,1.7,1.2 c0.2-0.4,0.3-0.9,0.3-1.4c0-0.2,0-0.3,0-0.5C81.5,55.1,81.1,54.7,80.5,54.7z"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M75.3,42.5c-9.9,0.4-17.6,8.8-17.6,18.7v10.3c0,1.8,1.5,3.3,3.3,3.3c1.8,0,3.3-1.5,3.3-3.3v-2.7h23.2v2.7 c0,1.8,1.5,3.3,3.3,3.3c1.8,0,3.3-1.5,3.3-3.3V60.7C94.2,50.4,85.7,42.1,75.3,42.5z M85.7,56.9c-0.6,1-1.5,1.7-2.6,2.1 c-0.7,1.4-2.2,2.4-3.9,2.4c-0.2,0-0.4,0-0.5,0c-0.6,0-1.1-0.5-1.1-1.1c0-0.6,0.5-1.1,1.1-1.1v0c0.5,0,1-0.2,1.4-0.4 c-0.6-0.3-1.2-0.7-1.6-1.3c-0.4-0.5-0.6-1-0.6-1.7c0-1.4,1.2-2.6,2.6-2.6c0.9,0,1.6,0.4,2.1,1.1c0.6,0.8,1,1.7,1,2.8 c0,0.1,0,0.2,0,0.4c0.5-0.2,0.9-0.6,1.2-1c0.2-0.3,0.5-0.3,0.8-0.2C85.8,56.2,85.9,56.6,85.7,56.9z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M128,44.6h4.5v2.2c0,1-0.1,1.9-0.1,1.9h0.1c0,0,2.2-4.6,8.5-4.6c6.8,0,11.1,5.4,11.1,13.3 c0,8.1-4.9,13.3-11.5,13.3c-5.6,0-7.8-4.2-7.8-4.2h-0.1c0,0,0.1,0.9,0.1,2.2v11.4H128V44.6z M139.9,66.4c4,0,7.3-3.3,7.3-9.1 c0-5.5-3-9.1-7.2-9.1c-3.8,0-7.3,2.7-7.3,9.1C132.7,61.9,135.2,66.4,139.9,66.4z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M170.9,44c7.6,0,13.7,5.6,13.7,13.2c0,7.7-6.1,13.3-13.7,13.3s-13.7-5.6-13.7-13.3 C157.2,49.6,163.3,44,170.9,44z M170.9,66.3c4.8,0,8.7-3.8,8.7-9.1c0-5.3-3.9-9-8.7-9c-4.8,0-8.7,3.8-8.7,9 C162.2,62.5,166.1,66.3,170.9,66.3z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M191.3,44.6h4.7V49c0,1.1-0.1,1.9-0.1,1.9h0.1c1.2-3.7,4.1-6.6,8-6.6c0.7,0,1.3,0.1,1.3,0.1v4.8 c0,0-0.7-0.1-1.4-0.1c-3.1,0-6,2.2-7.1,6c-0.5,1.5-0.6,3-0.6,4.6v10.4h-4.9V44.6z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M211,34.6h4.9v19.3h3.6l6.9-9.3h5.5l-8.4,11.2v0.1l9.4,14.1h-5.7L219.5,58h-3.7v11.9H211V34.6z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M238.3,34.6h4.9v11.6c0,1.3-0.1,2.2-0.1,2.2h0.1c0,0,2.2-4.3,8.1-4.3c6.8,0,11.1,5.4,11.1,13.3 c0,8.1-4.9,13.3-11.5,13.3c-5.7,0-8-4.4-8-4.4h-0.1c0,0,0.1,0.8,0.1,1.9v1.9h-4.6V34.6z M250.2,66.4c4,0,7.3-3.3,7.3-9.1 c0-5.5-3-9.1-7.2-9.1c-3.8,0-7.3,2.7-7.3,9.1C243,61.9,245.4,66.4,250.2,66.4z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M269,44.6h4.9v15.1c0,3.5,0.7,6.3,4.8,6.3c5.2,0,8.2-4.6,8.2-9.6V44.6h4.9v25.3H287v-3.4 c0-1.1,0.1-1.9,0.1-1.9H287c-1.1,2.5-4.4,5.8-9.3,5.8c-5.7,0-8.7-3-8.7-9.7V44.6z"
|
||||
/>
|
||||
<path
|
||||
fill="#212222"
|
||||
d="M300.3,44.6h4.7V48c0,1-0.1,1.9-0.1,1.9h0.1c1-2.2,4-5.8,9.5-5.8c6,0,8.7,3.3,8.7,9.7v16.2h-4.9V54.8 c0-3.6-0.8-6.4-4.8-6.4c-3.9,0-7,2.6-8,6.2c-0.3,1-0.4,2.2-0.4,3.4v11.9h-4.9V44.6z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const dnsProviderIcons = {
|
||||
cloudflare: CloudflareIcon,
|
||||
route53: Route53Icon,
|
||||
porkbun: PorkbunIcon,
|
||||
} as const;
|
||||
|
||||
1
apps/dokploy/drizzle/0189_wooden_nextwave.sql
Normal file
1
apps/dokploy/drizzle/0189_wooden_nextwave.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'porkbun';
|
||||
9147
apps/dokploy/drizzle/meta/0189_snapshot.json
Normal file
9147
apps/dokploy/drizzle/meta/0189_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1324,6 +1324,13 @@
|
||||
"when": 1788249519837,
|
||||
"tag": "0188_volatile_piledriver",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 189,
|
||||
"version": "7",
|
||||
"when": 1788251861403,
|
||||
"tag": "0189_wooden_nextwave",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { organization } from "./account";
|
||||
export const dnsProviderType = pgEnum("DnsProviderType", [
|
||||
"cloudflare",
|
||||
"route53",
|
||||
"porkbun",
|
||||
]);
|
||||
|
||||
export const cloudflareDnsConfigSchema = z.object({
|
||||
@ -20,9 +21,16 @@ export const route53DnsConfigSchema = z.object({
|
||||
secretAccessKey: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const porkbunDnsConfigSchema = z.object({
|
||||
providerType: z.literal("porkbun"),
|
||||
apiKey: z.string().trim().min(1),
|
||||
secretApiKey: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
cloudflareDnsConfigSchema,
|
||||
route53DnsConfigSchema,
|
||||
porkbunDnsConfigSchema,
|
||||
]);
|
||||
|
||||
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
|
||||
|
||||
@ -17,6 +17,7 @@ export const DNS_SECRET_MASK = "********";
|
||||
const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
|
||||
cloudflare: ["apiToken"],
|
||||
route53: ["secretAccessKey"],
|
||||
porkbun: ["secretApiKey"],
|
||||
};
|
||||
|
||||
export const maskDnsProviderConfig = (
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
|
||||
import { cloudflareClient } from "./cloudflare";
|
||||
import { porkbunClient } from "./porkbun";
|
||||
import { route53Client } from "./route53";
|
||||
import type { DnsClient } from "./types";
|
||||
|
||||
const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
|
||||
cloudflare: cloudflareClient as DnsClient,
|
||||
route53: route53Client as DnsClient,
|
||||
porkbun: porkbunClient as DnsClient,
|
||||
};
|
||||
|
||||
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>
|
||||
|
||||
134
packages/server/src/utils/dns/porkbun.ts
Normal file
134
packages/server/src/utils/dns/porkbun.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import type { porkbunDnsConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type DnsClient, dnsFetch } from "./types";
|
||||
|
||||
type PorkbunConfig = z.infer<typeof porkbunDnsConfigSchema>;
|
||||
|
||||
type PorkbunResponse<T> = T & {
|
||||
status: "SUCCESS" | "ERROR";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const PORKBUN_API = "https://api.porkbun.com/api/json/v3";
|
||||
|
||||
const pbFetch = async <T>(
|
||||
config: PorkbunConfig,
|
||||
path: string,
|
||||
body: Record<string, unknown> = {},
|
||||
): Promise<PorkbunResponse<T>> => {
|
||||
const response = await dnsFetch(`${PORKBUN_API}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
apikey: config.apiKey,
|
||||
secretapikey: config.secretApiKey,
|
||||
...body,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as PorkbunResponse<T>;
|
||||
if (!response.ok || result.status !== "SUCCESS") {
|
||||
throw new Error(
|
||||
`Porkbun: request to ${path} failed${
|
||||
result.message ? `: ${result.message}` : ` (status ${response.status})`
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Porkbun's "name" only accepts the subdomain portion, without the zone (domain) itself.
|
||||
const toSubdomain = (name: string, domain: string) => {
|
||||
if (name === domain) {
|
||||
return "";
|
||||
}
|
||||
const suffix = `.${domain}`;
|
||||
return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
|
||||
};
|
||||
|
||||
interface PorkbunRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
content: string;
|
||||
ttl: string;
|
||||
prio: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export const porkbunClient: DnsClient<PorkbunConfig> = {
|
||||
async listZones(config) {
|
||||
const result = await pbFetch<{ domains: { domain: string }[] }>(
|
||||
config,
|
||||
"/domain/listAll",
|
||||
);
|
||||
return result.domains.map((domain) => ({
|
||||
id: domain.domain,
|
||||
name: domain.domain,
|
||||
}));
|
||||
},
|
||||
|
||||
async listRecords(config, zoneId) {
|
||||
const result = await pbFetch<{ records: PorkbunRecord[] }>(
|
||||
config,
|
||||
`/dns/retrieve/${zoneId}`,
|
||||
);
|
||||
return result.records.map((record) => ({
|
||||
id: record.id,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: Number(record.ttl),
|
||||
}));
|
||||
},
|
||||
|
||||
async upsertRecord(config, record) {
|
||||
const subdomain = toSubdomain(record.name, record.zoneId);
|
||||
const existing = await pbFetch<{ records: PorkbunRecord[] }>(
|
||||
config,
|
||||
`/dns/retrieveByNameType/${record.zoneId}/${record.type}/${subdomain}`,
|
||||
);
|
||||
|
||||
const payload = {
|
||||
name: subdomain,
|
||||
type: record.type,
|
||||
content: record.content,
|
||||
ttl: record.ttl ?? 600,
|
||||
};
|
||||
|
||||
const existingRecord = existing.records[0];
|
||||
if (existingRecord) {
|
||||
await pbFetch(
|
||||
config,
|
||||
`/dns/edit/${record.zoneId}/${existingRecord.id}`,
|
||||
payload,
|
||||
);
|
||||
return { id: existingRecord.id };
|
||||
}
|
||||
|
||||
const created = await pbFetch<{ id: string }>(
|
||||
config,
|
||||
`/dns/create/${record.zoneId}`,
|
||||
payload,
|
||||
);
|
||||
return { id: created.id };
|
||||
},
|
||||
|
||||
async updateRecord(config, zoneId, recordId, record) {
|
||||
await pbFetch(config, `/dns/edit/${zoneId}/${recordId}`, {
|
||||
name: toSubdomain(record.name, zoneId),
|
||||
type: record.type,
|
||||
content: record.content,
|
||||
ttl: record.ttl ?? 600,
|
||||
});
|
||||
return { id: recordId };
|
||||
},
|
||||
|
||||
async deleteRecord(config, zoneId, recordId) {
|
||||
await pbFetch(config, `/dns/delete/${zoneId}/${recordId}`);
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await pbFetch(config, "/ping");
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user