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
@@ -0,0 +1,278 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateDnsProvider,
type DnsProviderConfig,
dnsProvider,
} from "@dokploy/server/db/schema";
import type { DnsRecordInput } from "@dokploy/server/utils/dns";
import { getDnsClient } from "@dokploy/server/utils/dns";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type DnsProvider = typeof dnsProvider.$inferSelect;
export const DNS_SECRET_MASK = "********";
const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
cloudflare: ["apiToken"],
route53: ["secretAccessKey"],
};
export const maskDnsProviderConfig = (
config: DnsProviderConfig,
): DnsProviderConfig => {
const masked: Record<string, unknown> = { ...config };
for (const field of SENSITIVE_FIELDS[config.providerType]) {
if (masked[field]) {
masked[field] = DNS_SECRET_MASK;
}
}
return masked as DnsProviderConfig;
};
export const mergeDnsProviderConfig = (
incoming: DnsProviderConfig,
existing: DnsProviderConfig,
): DnsProviderConfig => {
const merged: Record<string, unknown> = { ...incoming };
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
if (merged[field] === DNS_SECRET_MASK) {
if (incoming.providerType !== existing.providerType) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Credentials must be re-entered when changing the provider type",
});
}
merged[field] = (existing as Record<string, unknown>)[field];
}
}
return merged as DnsProviderConfig;
};
const isUniqueNameViolation = (error: unknown) =>
error instanceof Error && error.message.includes("dns_provider_org_name_idx");
export const createDnsProvider = async (
input: z.infer<typeof apiCreateDnsProvider>,
organizationId: string,
) => {
try {
const newProvider = await db
.insert(dnsProvider)
.values({
name: input.name,
providerType: input.config.providerType,
config: input.config,
organizationId,
})
.returning()
.then((value) => value[0]);
if (!newProvider) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the DNS provider",
});
}
return newProvider;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A DNS provider named "${input.name}" already exists in this organization`,
});
}
throw error;
}
};
export const findDnsProviderById = async (dnsProviderId: string) => {
const provider = await db.query.dnsProvider.findFirst({
where: eq(dnsProvider.dnsProviderId, dnsProviderId),
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message: "DNS provider not found",
});
}
return provider;
};
export const findDnsProviderInOrganization = async (
dnsProviderId: string,
organizationId: string,
) => {
const provider = await findDnsProviderById(dnsProviderId);
if (provider.organizationId !== organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not allowed to access this DNS provider",
});
}
return provider;
};
export const findDnsProvidersByOrganizationId = async (
organizationId: string,
) => {
return await db.query.dnsProvider.findMany({
where: eq(dnsProvider.organizationId, organizationId),
orderBy: (providers, { asc }) => [asc(providers.name)],
});
};
export const updateDnsProvider = async (
dnsProviderId: string,
name: string,
config: DnsProviderConfig,
) => {
const existing = await findDnsProviderById(dnsProviderId);
const mergedConfig = mergeDnsProviderConfig(config, existing.config);
try {
const updated = await db
.update(dnsProvider)
.set({
name,
providerType: mergedConfig.providerType,
config: mergedConfig,
})
.where(eq(dnsProvider.dnsProviderId, dnsProviderId))
.returning()
.then((res) => res[0]);
if (!updated) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error updating the DNS provider",
});
}
return updated;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A DNS provider named "${name}" already exists in this organization`,
});
}
throw error;
}
};
export const removeDnsProvider = async (dnsProviderId: string) => {
const removed = await db
.delete(dnsProvider)
.where(eq(dnsProvider.dnsProviderId, dnsProviderId))
.returning()
.then((res) => res[0]);
if (!removed) {
throw new TRPCError({
code: "NOT_FOUND",
message: "DNS provider not found",
});
}
return removed;
};
export const testDnsProviderConnection = async (config: DnsProviderConfig) => {
const client = getDnsClient(config.providerType);
try {
await client.testConnection(config);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error connecting to the DNS provider",
});
}
};
export const listDnsProviderZones = async (config: DnsProviderConfig) => {
const client = getDnsClient(config.providerType);
try {
return await client.listZones(config);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error listing zones for this DNS provider",
});
}
};
export const listDnsProviderRecords = async (
config: DnsProviderConfig,
zoneId: string,
) => {
const client = getDnsClient(config.providerType);
try {
return await client.listRecords(config, zoneId);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error listing records for this zone",
});
}
};
export const createDnsProviderRecord = async (
config: DnsProviderConfig,
record: DnsRecordInput,
) => {
const client = getDnsClient(config.providerType);
try {
return await client.upsertRecord(config, record);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Error creating the record",
});
}
};
export const updateDnsProviderRecord = async (
config: DnsProviderConfig,
zoneId: string,
recordId: string,
record: Omit<DnsRecordInput, "zoneId">,
) => {
const client = getDnsClient(config.providerType);
try {
return await client.updateRecord(config, zoneId, recordId, record);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Error updating the record",
});
}
};
export const deleteDnsProviderRecord = async (
config: DnsProviderConfig,
zoneId: string,
recordId: string,
) => {
const client = getDnsClient(config.providerType);
try {
await client.deleteRecord(config, zoneId, recordId);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Error deleting the record",
});
}
};