mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-20 20:14:29 +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:
@@ -93,4 +93,5 @@ export type AuditResourceType =
|
||||
| "application"
|
||||
| "compose"
|
||||
| "network"
|
||||
| "vaultProvider";
|
||||
| "vaultProvider"
|
||||
| "dnsProvider";
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const dnsProviderType = pgEnum("DnsProviderType", [
|
||||
"cloudflare",
|
||||
"route53",
|
||||
]);
|
||||
|
||||
export const cloudflareDnsConfigSchema = z.object({
|
||||
providerType: z.literal("cloudflare"),
|
||||
apiToken: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const route53DnsConfigSchema = z.object({
|
||||
providerType: z.literal("route53"),
|
||||
accessKeyId: z.string().trim().min(1),
|
||||
secretAccessKey: z.string().trim().min(1),
|
||||
endpoint: z.string().trim().url().optional(),
|
||||
});
|
||||
|
||||
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
cloudflareDnsConfigSchema,
|
||||
route53DnsConfigSchema,
|
||||
]);
|
||||
|
||||
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
|
||||
|
||||
export const dnsProvider = pgTable(
|
||||
"dns_provider",
|
||||
{
|
||||
dnsProviderId: text("dnsProviderId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
providerType: dnsProviderType("providerType").notNull(),
|
||||
config: jsonb("config").$type<DnsProviderConfig>().notNull(),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("dns_provider_org_name_idx").on(
|
||||
table.organizationId,
|
||||
table.name,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const dnsProviderNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
"Name can only contain letters, numbers, dashes and underscores",
|
||||
);
|
||||
|
||||
const createSchema = createInsertSchema(dnsProvider);
|
||||
|
||||
export const apiCreateDnsProvider = createSchema.pick({}).extend({
|
||||
name: dnsProviderNameSchema,
|
||||
config: dnsProviderConfigSchema,
|
||||
});
|
||||
|
||||
export const apiUpdateDnsProvider = createSchema.pick({}).extend({
|
||||
dnsProviderId: z.string().min(1),
|
||||
name: dnsProviderNameSchema,
|
||||
config: dnsProviderConfigSchema,
|
||||
});
|
||||
|
||||
export const apiFindOneDnsProvider = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveDnsProvider = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTestDnsProvider = z.object({
|
||||
dnsProviderId: z.string().min(1).optional(),
|
||||
config: dnsProviderConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export const apiListDnsZones = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiListDnsRecords = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
zoneId: z.string().min(1),
|
||||
});
|
||||
|
||||
const dnsRecordFieldsSchema = z.object({
|
||||
type: z.enum(["A", "CNAME"]),
|
||||
name: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
ttl: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateDnsRecord = dnsRecordFieldsSchema.extend({
|
||||
dnsProviderId: z.string().min(1),
|
||||
zoneId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiUpdateDnsRecord = dnsRecordFieldsSchema.extend({
|
||||
dnsProviderId: z.string().min(1),
|
||||
zoneId: z.string().min(1),
|
||||
recordId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiDeleteDnsRecord = z.object({
|
||||
dnsProviderId: z.string().min(1),
|
||||
zoneId: z.string().min(1),
|
||||
recordId: z.string().min(1),
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export * from "./certificate";
|
||||
export * from "./compose";
|
||||
export * from "./deployment";
|
||||
export * from "./destination";
|
||||
export * from "./dns-provider";
|
||||
export * from "./domain";
|
||||
export * from "./environment";
|
||||
export * from "./forward-auth";
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from "./services/cluster";
|
||||
export * from "./services/compose";
|
||||
export * from "./services/deployment";
|
||||
export * from "./services/destination";
|
||||
export * from "./services/dns-provider";
|
||||
export * from "./services/docker";
|
||||
export * from "./services/docker-volume";
|
||||
export * from "./services/domain";
|
||||
|
||||
@@ -49,6 +49,7 @@ export const statements = {
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
@@ -116,6 +118,7 @@ export const ownerRole = ac.newRole({
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -154,6 +157,7 @@ export const adminRole = ac.newRole({
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
dnsProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -198,4 +202,6 @@ export const memberRole = ac.newRole({
|
||||
auditLog: [],
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
vaultProvider: ["read"],
|
||||
// Members can see configured DNS providers to pick one when adding a domain
|
||||
dnsProvider: ["read"],
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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