Add multi-provider DNS registry for ACME DNS-01 challenges
Replace hardcoded Cloudflare DNS-01 with a data-driven provider registry supporting 11 providers (Cloudflare, Route 53, DigitalOcean, Duck DNS, Hetzner, Vultr, Porkbun, GoDaddy, Namecheap, OVH, Linode). Users can configure multiple providers with encrypted credentials and select a default. Per-certificate provider override is supported via providerOptions. - Add src/lib/dns-providers.ts with provider definitions, credential encrypt/decrypt, and Caddy config builder - Change DnsProviderSettings to multi-provider format with default selection - Auto-migrate legacy Cloudflare settings on startup (db.ts) - Normalize old single-provider format on read (getDnsProviderSettings) - Refactor buildTlsAutomation() to use provider registry - Add GET /api/v1/dns-providers endpoint for provider discovery - Add dns-provider settings group to REST API and instance sync - Replace Cloudflare settings card with multi-provider UI (add/remove providers, set default, dynamic credential forms) - Add 10 DNS provider modules to Caddy Dockerfile - Update OpenAPI spec, E2E tests, and unit test mocks Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-35
@@ -26,11 +26,11 @@ import db, { nowIso } from "./db";
|
||||
import { eq, isNull } from "drizzle-orm";
|
||||
import { config } from "./config";
|
||||
import {
|
||||
getCloudflareSettings,
|
||||
getGeneralSettings,
|
||||
getMetricsSettings,
|
||||
getLoggingSettings,
|
||||
getDnsSettings,
|
||||
getDnsProviderSettings,
|
||||
getUpstreamDnsResolutionSettings,
|
||||
getGeoBlockSettings,
|
||||
getWafSettings,
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type GeoBlockSettings,
|
||||
type WafSettings
|
||||
} from "./settings";
|
||||
import { buildDnsChallengeConfig, type DnsProviderCredentials } from "./dns-providers";
|
||||
import { syncInstances } from "./instance-sync";
|
||||
import {
|
||||
accessListEntries,
|
||||
@@ -1590,8 +1591,11 @@ async function buildTlsAutomation(
|
||||
};
|
||||
}
|
||||
|
||||
const cloudflare = await getCloudflareSettings();
|
||||
const hasCloudflare = cloudflare && cloudflare.apiToken;
|
||||
const dnsProviderSettings = await getDnsProviderSettings();
|
||||
const globalDnsProvider: DnsProviderCredentials | null =
|
||||
dnsProviderSettings?.default && dnsProviderSettings.providers[dnsProviderSettings.default]
|
||||
? { provider: dnsProviderSettings.default, credentials: dnsProviderSettings.providers[dnsProviderSettings.default] }
|
||||
: null;
|
||||
|
||||
const dnsSettings = options.dnsSettings ?? await getDnsSettings();
|
||||
const hasDnsResolvers = dnsSettings && dnsSettings.enabled && dnsSettings.resolvers && dnsSettings.resolvers.length > 0;
|
||||
@@ -1619,23 +1623,15 @@ async function buildTlsAutomation(
|
||||
issuer.email = options.acmeEmail;
|
||||
}
|
||||
|
||||
if (hasCloudflare) {
|
||||
const providerConfig: Record<string, string> = {
|
||||
name: "cloudflare",
|
||||
api_token: cloudflare.apiToken
|
||||
};
|
||||
|
||||
const dnsChallenge: Record<string, unknown> = {
|
||||
provider: providerConfig
|
||||
};
|
||||
|
||||
if (dnsResolvers.length > 0) {
|
||||
dnsChallenge.resolvers = dnsResolvers;
|
||||
if (globalDnsProvider) {
|
||||
const dnsChallenge = buildDnsChallengeConfig(
|
||||
globalDnsProvider.provider,
|
||||
globalDnsProvider.credentials,
|
||||
dnsResolvers
|
||||
);
|
||||
if (dnsChallenge) {
|
||||
issuer.challenges = { dns: dnsChallenge };
|
||||
}
|
||||
|
||||
issuer.challenges = {
|
||||
dns: dnsChallenge
|
||||
};
|
||||
}
|
||||
|
||||
policies.push({
|
||||
@@ -1654,6 +1650,16 @@ async function buildTlsAutomation(
|
||||
|
||||
managedCertificateIds.add(entry.certificate.id);
|
||||
|
||||
// Per-certificate provider override, falling back to global default
|
||||
let effectiveProvider = globalDnsProvider;
|
||||
const certOptions = entry.certificate.providerOptions as { provider?: string } | null;
|
||||
if (certOptions?.provider && dnsProviderSettings?.providers[certOptions.provider]) {
|
||||
effectiveProvider = {
|
||||
provider: certOptions.provider,
|
||||
credentials: dnsProviderSettings.providers[certOptions.provider],
|
||||
};
|
||||
}
|
||||
|
||||
for (const subjectGroup of groupHostPatternsByPriority(subjects)) {
|
||||
const issuer: Record<string, unknown> = {
|
||||
module: "acme"
|
||||
@@ -1663,23 +1669,15 @@ async function buildTlsAutomation(
|
||||
issuer.email = options.acmeEmail;
|
||||
}
|
||||
|
||||
if (hasCloudflare) {
|
||||
const providerConfig: Record<string, string> = {
|
||||
name: "cloudflare",
|
||||
api_token: cloudflare.apiToken
|
||||
};
|
||||
|
||||
const dnsChallenge: Record<string, unknown> = {
|
||||
provider: providerConfig
|
||||
};
|
||||
|
||||
if (dnsResolvers.length > 0) {
|
||||
dnsChallenge.resolvers = dnsResolvers;
|
||||
if (effectiveProvider) {
|
||||
const dnsChallenge = buildDnsChallengeConfig(
|
||||
effectiveProvider.provider,
|
||||
effectiveProvider.credentials,
|
||||
dnsResolvers
|
||||
);
|
||||
if (dnsChallenge) {
|
||||
issuer.challenges = { dns: dnsChallenge };
|
||||
}
|
||||
|
||||
issuer.challenges = {
|
||||
dns: dnsChallenge
|
||||
};
|
||||
}
|
||||
|
||||
policies.push({
|
||||
|
||||
@@ -252,9 +252,59 @@ function runEnvProviderSync() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration: convert legacy Cloudflare DNS settings to the new
|
||||
* generic dns_provider format. Idempotent — skips if already run or if
|
||||
* the new setting already exists.
|
||||
*/
|
||||
function runCloudflareToProviderMigration() {
|
||||
if (sqlitePath === ":memory:") return;
|
||||
|
||||
const { settings: settingsTable } = schema;
|
||||
|
||||
// Skip if migration already ran
|
||||
const flag = db.select().from(settingsTable).where(eq(settingsTable.key, "dns_provider_migrated")).get();
|
||||
if (flag) return;
|
||||
|
||||
// Skip if new dns_provider setting already exists (user already configured it)
|
||||
const existing = db.select().from(settingsTable).where(eq(settingsTable.key, "dns_provider")).get();
|
||||
if (existing) {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(settingsTable).values({ key: "dns_provider_migrated", value: "true", updatedAt: now }).run();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for legacy cloudflare setting
|
||||
const cfRow = db.select().from(settingsTable).where(eq(settingsTable.key, "cloudflare")).get();
|
||||
if (!cfRow) {
|
||||
const now = new Date().toISOString();
|
||||
db.insert(settingsTable).values({ key: "dns_provider_migrated", value: "true", updatedAt: now }).run();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cf = JSON.parse(cfRow.value) as { apiToken?: string; zoneId?: string; accountId?: string };
|
||||
if (cf.apiToken) {
|
||||
const now = new Date().toISOString();
|
||||
const newSetting = {
|
||||
providers: { cloudflare: { api_token: cf.apiToken } },
|
||||
default: "cloudflare",
|
||||
};
|
||||
db.insert(settingsTable).values({ key: "dns_provider", value: JSON.stringify(newSetting), updatedAt: now }).run();
|
||||
console.log("Migrated legacy Cloudflare DNS settings to dns_provider format");
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse legacy cloudflare setting during migration:", e);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
db.insert(settingsTable).values({ key: "dns_provider_migrated", value: "true", updatedAt: now }).run();
|
||||
}
|
||||
|
||||
try {
|
||||
runBetterAuthDataMigration();
|
||||
runEnvProviderSync();
|
||||
runCloudflareToProviderMigration();
|
||||
} catch (error) {
|
||||
console.warn("Better Auth data migration warning:", error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { encryptSecret, decryptSecret, isEncryptedSecret } from "./secret";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DnsProviderFieldType = "string" | "password";
|
||||
|
||||
export type DnsProviderField = {
|
||||
/** Key sent to Caddy config (e.g. "api_token") */
|
||||
key: string;
|
||||
/** Human-readable label */
|
||||
label: string;
|
||||
/** "password" fields are encrypted at rest */
|
||||
type: DnsProviderFieldType;
|
||||
/** Placeholder text for the input */
|
||||
placeholder?: string;
|
||||
/** Help text shown below the input */
|
||||
description?: string;
|
||||
/** Whether the field is required */
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export type DnsProviderDefinition = {
|
||||
/** Caddy DNS module name (e.g. "cloudflare", "route53") */
|
||||
name: string;
|
||||
/** Human-readable display name */
|
||||
displayName: string;
|
||||
/** Short description */
|
||||
description?: string;
|
||||
/** Link to caddy-dns module docs */
|
||||
docsUrl?: string;
|
||||
/** Credential fields this provider requires */
|
||||
fields: DnsProviderField[];
|
||||
/** caddy-dns Go module path (for Dockerfile reference) */
|
||||
modulePath: string;
|
||||
};
|
||||
|
||||
export type DnsProviderCredentials = {
|
||||
provider: string;
|
||||
credentials: Record<string, string>;
|
||||
};
|
||||
|
||||
// ─── Registry ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DNS_PROVIDERS: DnsProviderDefinition[] = [
|
||||
{
|
||||
name: "cloudflare",
|
||||
displayName: "Cloudflare",
|
||||
description: "Cloudflare DNS API",
|
||||
docsUrl: "https://github.com/caddy-dns/cloudflare",
|
||||
modulePath: "github.com/caddy-dns/cloudflare",
|
||||
fields: [
|
||||
{
|
||||
key: "api_token",
|
||||
label: "API Token",
|
||||
type: "password",
|
||||
required: true,
|
||||
placeholder: "Cloudflare API token with Zone:DNS:Edit permission",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "route53",
|
||||
displayName: "Amazon Route 53",
|
||||
description: "AWS Route 53 DNS API (supports IAM roles when fields are empty)",
|
||||
docsUrl: "https://github.com/caddy-dns/route53",
|
||||
modulePath: "github.com/caddy-dns/route53",
|
||||
fields: [
|
||||
{ key: "access_key_id", label: "Access Key ID", type: "string", required: false, placeholder: "AKIA..." },
|
||||
{ key: "secret_access_key", label: "Secret Access Key", type: "password", required: false },
|
||||
{ key: "region", label: "AWS Region", type: "string", required: false, placeholder: "us-east-1" },
|
||||
{
|
||||
key: "hosted_zone_id",
|
||||
label: "Hosted Zone ID",
|
||||
type: "string",
|
||||
required: false,
|
||||
placeholder: "Z1234567890",
|
||||
description: "Optional. Required only if you have multiple zones for the same domain.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "digitalocean",
|
||||
displayName: "DigitalOcean",
|
||||
description: "DigitalOcean DNS API",
|
||||
docsUrl: "https://github.com/caddy-dns/digitalocean",
|
||||
modulePath: "github.com/caddy-dns/digitalocean",
|
||||
fields: [
|
||||
{ key: "api_token", label: "API Token", type: "password", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "duckdns",
|
||||
displayName: "Duck DNS",
|
||||
description: "Duck DNS dynamic DNS service",
|
||||
docsUrl: "https://github.com/caddy-dns/duckdns",
|
||||
modulePath: "github.com/caddy-dns/duckdns",
|
||||
fields: [
|
||||
{ key: "api_token", label: "Token", type: "password", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "hetzner",
|
||||
displayName: "Hetzner",
|
||||
description: "Hetzner DNS API",
|
||||
docsUrl: "https://github.com/caddy-dns/hetzner",
|
||||
modulePath: "github.com/caddy-dns/hetzner",
|
||||
fields: [
|
||||
{ key: "api_token", label: "API Token", type: "password", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "vultr",
|
||||
displayName: "Vultr",
|
||||
description: "Vultr DNS API",
|
||||
docsUrl: "https://github.com/caddy-dns/vultr",
|
||||
modulePath: "github.com/caddy-dns/vultr",
|
||||
fields: [
|
||||
{ key: "api_token", label: "API Key", type: "password", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "porkbun",
|
||||
displayName: "Porkbun",
|
||||
description: "Porkbun DNS API",
|
||||
docsUrl: "https://github.com/caddy-dns/porkbun",
|
||||
modulePath: "github.com/caddy-dns/porkbun",
|
||||
fields: [
|
||||
{ key: "api_key", label: "API Key", type: "password", required: true },
|
||||
{ key: "api_secret_key", label: "API Secret Key", type: "password", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "godaddy",
|
||||
displayName: "GoDaddy",
|
||||
description: "GoDaddy DNS API",
|
||||
docsUrl: "https://github.com/caddy-dns/godaddy",
|
||||
modulePath: "github.com/caddy-dns/godaddy",
|
||||
fields: [
|
||||
{
|
||||
key: "api_token",
|
||||
label: "API Key:Secret",
|
||||
type: "password",
|
||||
required: true,
|
||||
placeholder: "key:secret",
|
||||
description: "Format: API_KEY:API_SECRET",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "namecheap",
|
||||
displayName: "Namecheap",
|
||||
description: "Namecheap DNS API",
|
||||
docsUrl: "https://github.com/caddy-dns/namecheap",
|
||||
modulePath: "github.com/caddy-dns/namecheap",
|
||||
fields: [
|
||||
{ key: "api_key", label: "API Key", type: "password", required: true },
|
||||
{ key: "user", label: "Username", type: "string", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ovh",
|
||||
displayName: "OVH",
|
||||
description: "OVH DNS API",
|
||||
docsUrl: "https://github.com/caddy-dns/ovh",
|
||||
modulePath: "github.com/caddy-dns/ovh",
|
||||
fields: [
|
||||
{ key: "endpoint", label: "Endpoint", type: "string", required: true, placeholder: "ovh-eu" },
|
||||
{ key: "application_key", label: "Application Key", type: "string", required: true },
|
||||
{ key: "application_secret", label: "Application Secret", type: "password", required: true },
|
||||
{ key: "consumer_key", label: "Consumer Key", type: "password", required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "linode",
|
||||
displayName: "Linode (Akamai)",
|
||||
description: "Linode/Akamai DNS API",
|
||||
docsUrl: "https://github.com/caddy-dns/linode",
|
||||
modulePath: "github.com/caddy-dns/linode",
|
||||
fields: [
|
||||
{ key: "api_token", label: "API Token", type: "password", required: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getProviderDefinition(name: string): DnsProviderDefinition | undefined {
|
||||
return DNS_PROVIDERS.find((p) => p.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt password-type credential fields for storage.
|
||||
* Non-password fields and already-encrypted values are left unchanged.
|
||||
*/
|
||||
export function encryptProviderCredentials(
|
||||
providerName: string,
|
||||
credentials: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const def = getProviderDefinition(providerName);
|
||||
if (!def) return credentials;
|
||||
|
||||
const result = { ...credentials };
|
||||
for (const field of def.fields) {
|
||||
if (field.type === "password" && result[field.key] && !isEncryptedSecret(result[field.key])) {
|
||||
result[field.key] = encryptSecret(result[field.key]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt password-type credential fields for use in Caddy config.
|
||||
*/
|
||||
export function decryptProviderCredentials(
|
||||
providerName: string,
|
||||
credentials: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const def = getProviderDefinition(providerName);
|
||||
if (!def) return credentials;
|
||||
|
||||
const result = { ...credentials };
|
||||
for (const field of def.fields) {
|
||||
if (field.type === "password" && result[field.key] && isEncryptedSecret(result[field.key])) {
|
||||
result[field.key] = decryptSecret(result[field.key]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Caddy DNS challenge provider config from a provider name + credentials.
|
||||
* Returns the object to set as `issuer.challenges.dns`.
|
||||
*/
|
||||
export function buildDnsChallengeConfig(
|
||||
providerName: string,
|
||||
credentials: Record<string, string>,
|
||||
dnsResolvers: string[]
|
||||
): Record<string, unknown> | null {
|
||||
const def = getProviderDefinition(providerName);
|
||||
if (!def) return null;
|
||||
|
||||
const decrypted = decryptProviderCredentials(providerName, credentials);
|
||||
|
||||
// Build provider config: { name: "cloudflare", api_token: "..." }
|
||||
const providerConfig: Record<string, string> = { name: providerName };
|
||||
for (const [key, value] of Object.entries(decrypted)) {
|
||||
if (value) {
|
||||
providerConfig[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const dnsChallenge: Record<string, unknown> = { provider: providerConfig };
|
||||
if (dnsResolvers.length > 0) {
|
||||
dnsChallenge.resolvers = dnsResolvers;
|
||||
}
|
||||
|
||||
return dnsChallenge;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type InstanceMode = "standalone" | "master" | "slave";
|
||||
export type SyncSettings = {
|
||||
general: unknown | null;
|
||||
cloudflare: unknown | null;
|
||||
dns_provider: unknown | null;
|
||||
authentik: unknown | null;
|
||||
metrics: unknown | null;
|
||||
logging: unknown | null;
|
||||
@@ -249,6 +250,7 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
|
||||
const settings = {
|
||||
general: await getSetting("general"),
|
||||
cloudflare: await getSetting("cloudflare"),
|
||||
dns_provider: await getSetting("dns_provider"),
|
||||
authentik: await getSetting("authentik"),
|
||||
metrics: await getSetting("metrics"),
|
||||
logging: await getSetting("logging"),
|
||||
@@ -422,6 +424,7 @@ export async function syncInstances(): Promise<{ total: number; success: number;
|
||||
export async function applySyncPayload(payload: SyncPayload) {
|
||||
await setSyncedSetting("general", payload.settings.general);
|
||||
await setSyncedSetting("cloudflare", payload.settings.cloudflare);
|
||||
await setSyncedSetting("dns_provider", payload.settings.dns_provider ?? null);
|
||||
await setSyncedSetting("authentik", payload.settings.authentik);
|
||||
await setSyncedSetting("metrics", payload.settings.metrics);
|
||||
await setSyncedSetting("logging", payload.settings.logging);
|
||||
|
||||
@@ -38,6 +38,13 @@ export type DnsSettings = {
|
||||
timeout?: string; // DNS query timeout (e.g., "5s")
|
||||
};
|
||||
|
||||
export type DnsProviderSettings = {
|
||||
/** Configured providers: keyed by provider name, value is credential map */
|
||||
providers: Record<string, Record<string, string>>;
|
||||
/** Name of the default provider (null = no DNS-01 challenges) */
|
||||
default: string | null;
|
||||
};
|
||||
|
||||
export type UpstreamDnsAddressFamily = "ipv6" | "ipv4" | "both";
|
||||
|
||||
export type UpstreamDnsResolutionSettings = {
|
||||
@@ -195,6 +202,25 @@ export async function saveDnsSettings(settings: DnsSettings): Promise<void> {
|
||||
await setSetting("dns", settings);
|
||||
}
|
||||
|
||||
export async function getDnsProviderSettings(): Promise<DnsProviderSettings | null> {
|
||||
const raw = await getEffectiveSetting<Record<string, unknown>>("dns_provider");
|
||||
if (!raw) return null;
|
||||
|
||||
// Normalize old single-provider format { provider, credentials }
|
||||
// to new multi-provider format { providers, default }
|
||||
if ("provider" in raw && "credentials" in raw && !("providers" in raw)) {
|
||||
const name = raw.provider as string;
|
||||
const creds = raw.credentials as Record<string, string>;
|
||||
return { providers: { [name]: creds }, default: name };
|
||||
}
|
||||
|
||||
return raw as unknown as DnsProviderSettings;
|
||||
}
|
||||
|
||||
export async function saveDnsProviderSettings(settings: DnsProviderSettings): Promise<void> {
|
||||
await setSetting("dns_provider", settings);
|
||||
}
|
||||
|
||||
export async function getUpstreamDnsResolutionSettings(): Promise<UpstreamDnsResolutionSettings | null> {
|
||||
return await getEffectiveSetting<UpstreamDnsResolutionSettings>("upstream_dns_resolution");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user