mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-21 16:05:25 +00:00
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:
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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 }));
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user