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
+2 -1
View File
@@ -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),
});
+1
View File
@@ -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";