feat: add DNS provider integration (Cloudflare, AWS Route53)

Lets you connect a DNS provider and manage its records (create,
update, delete) from Settings -> DNS Providers, instead of doing it
by hand in Cloudflare/AWS.

- dns_provider table, org-scoped, jsonb config as a discriminated
  union per provider type
- Cloudflare adapter (REST, bearer token)
- Route53 adapter (AWS SDK, SigV4); records are identified by
  type:name since Route53 has no native record id, so update
  handles renames as delete-old + upsert-new
- listZones/listRecords/createRecord/updateRecord/deleteRecord/
  testConnection wired through a shared DnsClient interface
- Settings UI: provider management, zone browser, record CRUD,
  IP-fill dropdown for A records (panel IP + remote servers)
- Access control: dnsProvider resource wired into custom roles
- Unit tests for both adapters and the config mask/merge logic
This commit is contained in:
Mauricio Siu
2026-08-12 03:25:25 -06:00
parent 3848fa9c0d
commit 4d58f3987f
29 changed files with 12233 additions and 18 deletions
+143
View File
@@ -0,0 +1,143 @@
import type { cloudflareDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type CloudflareConfig = z.infer<typeof cloudflareDnsConfigSchema>;
type CloudflareResponse<T> = {
success: boolean;
errors: { code: number; message: string }[];
result: T;
result_info?: { page: number; per_page: number; total_pages: number };
};
const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
const cfFetch = async <T>(
config: CloudflareConfig,
path: string,
init: RequestInit = {},
): Promise<T> => {
const response = await dnsFetch(`${CLOUDFLARE_API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${config.apiToken.trim()}`,
"Content-Type": "application/json",
...init.headers,
},
});
const body = (await response.json()) as CloudflareResponse<T>;
if (!response.ok || !body.success) {
const detail = body.errors?.map((e) => e.message).join(", ");
throw new Error(
`Cloudflare: request to ${path} failed${detail ? `: ${detail}` : ` (status ${response.status})`}`,
);
}
return body.result;
};
export const cloudflareClient: DnsClient<CloudflareConfig> = {
async listZones(config) {
const zones: { id: string; name: string }[] = [];
let page = 1;
while (true) {
const result = await cfFetch<{ id: string; name: string }[]>(
config,
`/zones?per_page=50&page=${page}`,
);
zones.push(...result.map((zone) => ({ id: zone.id, name: zone.name })));
if (result.length < 50) {
break;
}
page += 1;
}
return zones;
},
async listRecords(config, zoneId) {
const records: {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}[] = [];
let page = 1;
while (true) {
const result = await cfFetch<
{
id: string;
type: string;
name: string;
content: string;
ttl: number;
}[]
>(config, `/zones/${zoneId}/dns_records?per_page=50&page=${page}`);
records.push(...result);
if (result.length < 50) {
break;
}
page += 1;
}
return records;
},
async upsertRecord(config, record) {
const existing = await cfFetch<{ id: string }[]>(
config,
`/zones/${record.zoneId}/dns_records?type=${record.type}&name=${encodeURIComponent(record.name)}`,
);
const payload = {
type: record.type,
name: record.name,
content: record.content,
ttl: record.ttl ?? 1,
};
const existingRecord = existing[0];
if (existingRecord) {
const updated = await cfFetch<{ id: string }>(
config,
`/zones/${record.zoneId}/dns_records/${existingRecord.id}`,
{ method: "PUT", body: JSON.stringify(payload) },
);
return { id: updated.id };
}
const created = await cfFetch<{ id: string }>(
config,
`/zones/${record.zoneId}/dns_records`,
{ method: "POST", body: JSON.stringify(payload) },
);
return { id: created.id };
},
async updateRecord(config, zoneId, recordId, record) {
const updated = await cfFetch<{ id: string }>(
config,
`/zones/${zoneId}/dns_records/${recordId}`,
{
method: "PUT",
body: JSON.stringify({
type: record.type,
name: record.name,
content: record.content,
ttl: record.ttl ?? 1,
}),
},
);
return { id: updated.id };
},
async deleteRecord(config, zoneId, recordId) {
await cfFetch(config, `/zones/${zoneId}/dns_records/${recordId}`, {
method: "DELETE",
});
},
async testConnection(config) {
await cfFetch(config, "/zones?per_page=1");
},
};
+14
View File
@@ -0,0 +1,14 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import { cloudflareClient } from "./cloudflare";
import { route53Client } from "./route53";
import type { DnsClient } from "./types";
const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
cloudflare: cloudflareClient as DnsClient,
route53: route53Client as DnsClient,
};
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>
clients[providerType];
export * from "./types";
+216
View File
@@ -0,0 +1,216 @@
import {
ChangeResourceRecordSetsCommand,
ListHostedZonesCommand,
ListResourceRecordSetsCommand,
type ResourceRecordSet,
Route53Client,
} from "@aws-sdk/client-route-53";
import type { route53DnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import type { DnsClient } from "./types";
type Route53Config = z.infer<typeof route53DnsConfigSchema>;
const createClient = (config: Route53Config) =>
new Route53Client({
region: "us-east-1",
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
...(config.endpoint && { endpoint: config.endpoint }),
});
const stripTrailingDot = (name: string) => name.replace(/\.$/, "");
const ensureTrailingDot = (name: string) =>
name.endsWith(".") ? name : `${name}.`;
const stripZonePrefix = (id: string) => id.replace(/^\/hostedzone\//, "");
const buildRecordId = (type: string, name: string) =>
`${type}:${stripTrailingDot(name)}`;
const parseRecordId = (id: string) => {
const separatorIndex = id.indexOf(":");
if (separatorIndex === -1) {
throw new Error(`Invalid Route53 record id: "${id}"`);
}
return {
type: id.slice(0, separatorIndex),
name: id.slice(separatorIndex + 1),
};
};
const findExactRecordSet = async (
config: Route53Config,
zoneId: string,
type: string,
name: string,
): Promise<ResourceRecordSet | undefined> => {
const client = createClient(config);
const response = await client.send(
new ListResourceRecordSetsCommand({
HostedZoneId: zoneId,
StartRecordName: ensureTrailingDot(name),
StartRecordType: type as ResourceRecordSet["Type"],
MaxItems: 1,
}),
);
const candidate = response.ResourceRecordSets?.[0];
if (
candidate &&
candidate.Type === type &&
stripTrailingDot(candidate.Name ?? "") === stripTrailingDot(name)
) {
return candidate;
}
return undefined;
};
const buildRecordSet = (record: {
type: "A" | "CNAME";
name: string;
content: string;
ttl?: number;
}): ResourceRecordSet => ({
Name: ensureTrailingDot(record.name),
Type: record.type,
TTL: record.ttl ?? 300,
ResourceRecords: [{ Value: record.content }],
});
export const route53Client: DnsClient<Route53Config> = {
async listZones(config) {
const client = createClient(config);
const zones: { id: string; name: string }[] = [];
let marker: string | undefined;
do {
const response = await client.send(
new ListHostedZonesCommand({ Marker: marker, MaxItems: 100 }),
);
for (const zone of response.HostedZones ?? []) {
if (zone.Id && zone.Name) {
zones.push({
id: stripZonePrefix(zone.Id),
name: stripTrailingDot(zone.Name),
});
}
}
marker = response.IsTruncated ? response.NextMarker : undefined;
} while (marker);
return zones;
},
async listRecords(config, zoneId) {
const client = createClient(config);
const records: {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}[] = [];
let nextName: string | undefined;
let nextType: string | undefined;
do {
const response = await client.send(
new ListResourceRecordSetsCommand({
HostedZoneId: zoneId,
StartRecordName: nextName,
StartRecordType: nextType as ResourceRecordSet["Type"] | undefined,
}),
);
for (const set of response.ResourceRecordSets ?? []) {
if (!set.Name || !set.Type || !set.ResourceRecords?.length) {
continue;
}
records.push({
id: buildRecordId(set.Type, set.Name),
type: set.Type,
name: stripTrailingDot(set.Name),
content: set.ResourceRecords.map((r) => r.Value).join(", "),
ttl: set.TTL ?? 300,
});
}
nextName = response.IsTruncated ? response.NextRecordName : undefined;
nextType = response.IsTruncated ? response.NextRecordType : undefined;
} while (nextName);
return records;
},
async upsertRecord(config, record) {
const client = createClient(config);
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: record.zoneId,
ChangeBatch: {
Changes: [
{ Action: "UPSERT", ResourceRecordSet: buildRecordSet(record) },
],
},
}),
);
return { id: buildRecordId(record.type, record.name) };
},
async updateRecord(config, zoneId, recordIdInput, record) {
const { type: oldType, name: oldName } = parseRecordId(recordIdInput);
const client = createClient(config);
const changes: {
Action: "DELETE" | "UPSERT";
ResourceRecordSet: ResourceRecordSet;
}[] = [];
const sameIdentity =
oldType === record.type &&
stripTrailingDot(oldName) === stripTrailingDot(record.name);
if (!sameIdentity) {
const existing = await findExactRecordSet(
config,
zoneId,
oldType,
oldName,
);
if (existing) {
changes.push({ Action: "DELETE", ResourceRecordSet: existing });
}
}
changes.push({
Action: "UPSERT",
ResourceRecordSet: buildRecordSet(record),
});
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: zoneId,
ChangeBatch: { Changes: changes },
}),
);
return { id: buildRecordId(record.type, record.name) };
},
async deleteRecord(config, zoneId, recordIdInput) {
const { type, name } = parseRecordId(recordIdInput);
const existing = await findExactRecordSet(config, zoneId, type, name);
if (!existing) {
throw new Error(`Route53: record "${name}" (${type}) not found`);
}
const client = createClient(config);
await client.send(
new ChangeResourceRecordSetsCommand({
HostedZoneId: zoneId,
ChangeBatch: {
Changes: [{ Action: "DELETE", ResourceRecordSet: existing }],
},
}),
);
},
async testConnection(config) {
const client = createClient(config);
await client.send(new ListHostedZonesCommand({ MaxItems: 1 }));
},
};
+45
View File
@@ -0,0 +1,45 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
export interface DnsZone {
id: string;
name: string;
}
export interface DnsRecordInput {
zoneId: string;
type: "A" | "CNAME";
name: string;
content: string;
ttl?: number;
}
export interface DnsRecord {
id: string;
type: string;
name: string;
content: string;
ttl: number;
}
export interface DnsClient<C extends DnsProviderConfig = DnsProviderConfig> {
listZones(config: C): Promise<DnsZone[]>;
listRecords(config: C, zoneId: string): Promise<DnsRecord[]>;
upsertRecord(config: C, record: DnsRecordInput): Promise<{ id: string }>;
updateRecord(
config: C,
zoneId: string,
recordId: string,
record: Omit<DnsRecordInput, "zoneId">,
): Promise<{ id: string }>;
deleteRecord(config: C, zoneId: string, recordId: string): Promise<void>;
testConnection(config: C): Promise<void>;
}
export const DNS_REQUEST_TIMEOUT_MS = 15_000;
export const dnsFetch = async (url: string, init: RequestInit = {}) => {
return await fetch(url, {
...init,
signal: AbortSignal.timeout(DNS_REQUEST_TIMEOUT_MS),
});
};