diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx index d65bbe64..f7af7e47 100644 --- a/app/(dashboard)/settings/SettingsClient.tsx +++ b/app/(dashboard)/settings/SettingsClient.tsx @@ -20,14 +20,16 @@ import type { MetricsSettings, LoggingSettings, DnsSettings, + DnsProviderSettings, UpstreamDnsResolutionSettings, GeoBlockSettings, } from "@/lib/settings"; +import type { DnsProviderDefinition } from "@/src/lib/dns-providers"; import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields"; import OAuthProvidersSection from "./OAuthProvidersSection"; import type { OAuthProvider } from "@/src/lib/models/oauth-providers"; import { - updateCloudflareSettingsAction, + updateDnsProviderSettingsAction, updateGeneralSettingsAction, updateAuthentikSettingsAction, updateMetricsSettingsAction, @@ -112,7 +114,7 @@ function SettingSection({ const A: Record = { sync: { border: "border-l-violet-500", icon: "border-violet-500/30 bg-violet-500/10 text-violet-500" }, general: { border: "border-l-zinc-400", icon: "border-zinc-500/30 bg-zinc-500/10 text-zinc-500" }, - cloudflare: { border: "border-l-orange-500", icon: "border-orange-500/30 bg-orange-500/10 text-orange-500" }, + dnsProvider:{ border: "border-l-orange-500", icon: "border-orange-500/30 bg-orange-500/10 text-orange-500" }, dns: { border: "border-l-cyan-500", icon: "border-cyan-500/30 bg-cyan-500/10 text-cyan-500" }, upstreamDns:{ border: "border-l-emerald-500", icon: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500" }, authentik: { border: "border-l-purple-500", icon: "border-purple-500/30 bg-purple-500/10 text-purple-500" }, @@ -126,11 +128,8 @@ const A: Record = { type Props = { general: GeneralSettings | null; - cloudflare: { - hasToken: boolean; - zoneId?: string; - accountId?: string; - }; + dnsProvider: DnsProviderSettings | null; + dnsProviderDefinitions: DnsProviderDefinition[]; authentik: AuthentikSettings | null; metrics: MetricsSettings | null; logging: LoggingSettings | null; @@ -145,7 +144,7 @@ type Props = { tokenFromEnv: boolean; overrides: { general: boolean; - cloudflare: boolean; + dnsProvider: boolean; authentik: boolean; metrics: boolean; logging: boolean; @@ -178,7 +177,8 @@ type Props = { export default function SettingsClient({ general, - cloudflare, + dnsProvider, + dnsProviderDefinitions, authentik, metrics, logging, @@ -190,7 +190,9 @@ export default function SettingsClient({ instanceSync }: Props) { const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null); - const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null); + const [dnsProviderState, dnsProviderFormAction] = useFormState(updateDnsProviderSettingsAction, null); + const [selectedProvider, setSelectedProvider] = useState("none"); + const configuredProviders = dnsProvider?.providers ? Object.keys(dnsProvider.providers) : []; const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null); const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null); const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null); @@ -207,7 +209,7 @@ export default function SettingsClient({ const isSlave = instanceSync.mode === "slave"; const isMaster = instanceSync.mode === "master"; const [generalOverride, setGeneralOverride] = useState(instanceSync.overrides.general); - const [cloudflareOverride, setCloudflareOverride] = useState(instanceSync.overrides.cloudflare); + const [dnsProviderOverride, setDnsProviderOverride] = useState(instanceSync.overrides.dnsProvider); const [authentikOverride, setAuthentikOverride] = useState(instanceSync.overrides.authentik); const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics); const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging); @@ -463,65 +465,159 @@ export default function SettingsClient({ - {/* ── Cloudflare DNS ── */} + {/* ── DNS Providers ── */} } - title="Cloudflare DNS" - description="Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates." - accent={A.cloudflare} + title="DNS Providers" + description="Configure DNS providers for ACME DNS-01 challenges (required for wildcard certificates). You can add multiple providers and select a default." + accent={A.dnsProvider} > - {cloudflare.hasToken && ( - - A Cloudflare API token is already configured. Leave the token field blank to keep it, or select “Remove existing token” to delete it. - + {dnsProviderState?.message && ( + )} -
- {cloudflareState?.message && ( - - )} - {isSlave && ( -
- setCloudflareOverride(!!v)} - /> - -
- )} -
- - -
+ {isSlave && (
setDnsProviderOverride(!!v)} /> - +
-
-
- - -
-
- - -
+ )} + + {/* Configured providers list */} + {configuredProviders.length > 0 && ( +
+ + {configuredProviders.map((name) => { + const def = dnsProviderDefinitions.find((p) => p.name === name); + const isDefault = dnsProvider?.default === name; + return ( +
+
+ {def?.displayName ?? name} + {isDefault && } +
+
+ {!isDefault && ( + + + + {isSlave && } + + + )} +
+ + + {isSlave && } + +
+
+
+ ); + })} + {dnsProvider?.default && ( +
+ + + {isSlave && } + +
+ )}
+ )} + + {/* Add / update provider form */} +
+ + +
+ + +
+ + {/* Dynamic credential fields */} + {selectedProvider && selectedProvider !== "none" && (() => { + const providerDef = dnsProviderDefinitions.find((p) => p.name === selectedProvider); + if (!providerDef) return null; + const isUpdate = configuredProviders.includes(selectedProvider); + return ( +
+ {providerDef.description && ( +

{providerDef.description}

+ )} + {providerDef.fields.map((field) => ( +
+ + + {field.description && ( +

{field.description}

+ )} +
+ ))} + {isUpdate && ( + + Credentials are already configured. Leave fields blank to keep existing values. + + )} + {providerDef.docsUrl && ( +

+ + Provider documentation + +

+ )} +
+ ); + })()} + + {isSlave && }
- +
diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index a51d0871..653d31f7 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -5,10 +5,11 @@ import { requireAdmin } from "@/src/lib/auth"; import { applyCaddyConfig } from "@/src/lib/caddy"; import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync"; import { createInstance, deleteInstance, updateInstance } from "@/src/lib/models/instances"; -import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings, saveGeoBlockSettings, saveWafSettings, getWafSettings } from "@/src/lib/settings"; +import { clearSetting, getSetting, saveCloudflareSettings, getDnsProviderSettings, saveDnsProviderSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings, saveGeoBlockSettings, saveWafSettings, getWafSettings } from "@/src/lib/settings"; import { listProxyHosts, updateProxyHost } from "@/src/lib/models/proxy-hosts"; import { getWafRuleMessages } from "@/src/lib/models/waf-events"; -import type { CloudflareSettings, GeoBlockSettings, WafSettings } from "@/src/lib/settings"; +import type { CloudflareSettings, DnsProviderSettings, GeoBlockSettings, WafSettings } from "@/src/lib/settings"; +import { getProviderDefinition, encryptProviderCredentials } from "@/src/lib/dns-providers"; type ActionResult = { success: boolean; @@ -113,6 +114,122 @@ export async function updateCloudflareSettingsAction(_prevState: ActionResult | } } +export async function updateDnsProviderSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise { + try { + await requireAdmin(); + const mode = await getInstanceMode(); + const overrideEnabled = formData.get("overrideEnabled") === "on"; + if (mode === "slave" && !overrideEnabled) { + await clearSetting("dns_provider"); + try { + await applyCaddyConfig(); + revalidatePath("/settings"); + return { success: true, message: "DNS provider settings reset to master defaults" }; + } catch (error) { + console.error("Failed to apply Caddy config:", error); + revalidatePath("/settings"); + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + await syncInstances(); + return { success: true, message: `Settings reset, but could not apply to Caddy: ${errorMsg}` }; + } + } + + const action = String(formData.get("action") ?? "save").trim(); + const providerName = String(formData.get("provider") ?? "").trim(); + const current = await getDnsProviderSettings(); + const settings: DnsProviderSettings = current ?? { providers: {}, default: null }; + + if (action === "remove") { + if (!providerName || !settings.providers[providerName]) { + return { success: false, message: "No provider to remove" }; + } + const def = getProviderDefinition(providerName); + delete settings.providers[providerName]; + if (settings.default === providerName) { + // Pick next configured provider, or null + const remaining = Object.keys(settings.providers); + settings.default = remaining.length > 0 ? remaining[0] : null; + } + await saveDnsProviderSettings(settings); + await syncInstances(); + try { await applyCaddyConfig(); } catch { /* non-fatal */ } + revalidatePath("/settings"); + return { success: true, message: `${def?.displayName ?? providerName} removed${settings.default ? `. Default is now ${settings.default}.` : "."}` }; + } + + if (action === "set-default") { + const newDefault = providerName === "none" ? null : providerName; + if (newDefault && !settings.providers[newDefault]) { + return { success: false, message: `Cannot set default: ${providerName} is not configured` }; + } + settings.default = newDefault; + await saveDnsProviderSettings(settings); + await syncInstances(); + try { await applyCaddyConfig(); } catch { /* non-fatal */ } + revalidatePath("/settings"); + const label = newDefault ? (getProviderDefinition(newDefault)?.displayName ?? newDefault) : "None"; + return { success: true, message: `Default DNS provider set to ${label}` }; + } + + // action === "save": add or update a provider's credentials + if (!providerName || providerName === "none") { + return { success: false, message: "Select a provider to configure" }; + } + + const def = getProviderDefinition(providerName); + if (!def) { + return { success: false, message: `Unknown DNS provider: ${providerName}` }; + } + + const existingCreds = settings.providers[providerName]; + + // Collect credentials from form + const credentials: Record = {}; + for (const field of def.fields) { + const rawValue = formData.get(`credential_${field.key}`); + const value = rawValue ? String(rawValue).trim() : ""; + if (value) { + credentials[field.key] = value; + } else if (existingCreds?.[field.key]) { + credentials[field.key] = existingCreds[field.key]; + } + } + + // Validate required fields + for (const field of def.fields) { + if (field.required && !credentials[field.key]) { + return { success: false, message: `${field.label} is required for ${def.displayName}` }; + } + } + + // Encrypt password fields before storing + settings.providers[providerName] = encryptProviderCredentials(providerName, credentials); + + // If this is the first provider, make it the default + if (!settings.default) { + settings.default = providerName; + } + + await saveDnsProviderSettings(settings); + await syncInstances(); + + try { + await applyCaddyConfig(); + revalidatePath("/settings"); + const isDefault = settings.default === providerName; + return { success: true, message: `${def.displayName} saved${isDefault ? " (default)" : ""}` }; + } catch (error) { + console.error("Failed to apply Caddy config:", error); + revalidatePath("/settings"); + const errorMsg = error instanceof Error ? error.message : "Unknown error"; + return { success: true, message: `Settings saved, but could not apply to Caddy: ${errorMsg}` }; + } + } catch (error) { + console.error("Failed to save DNS provider settings:", error); + return { success: false, message: error instanceof Error ? error.message : "Failed to save DNS provider settings" }; + } +} + export async function updateAuthentikSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise { try { await requireAdmin(); diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 5ec6fd12..0d802198 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -1,8 +1,9 @@ import SettingsClient from "./SettingsClient"; -import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings } from "@/src/lib/settings"; +import { getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getDnsProviderSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings } from "@/src/lib/settings"; import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync"; import { listInstances } from "@/src/lib/models/instances"; import { listOAuthProviders } from "@/src/lib/models/oauth-providers"; +import { DNS_PROVIDERS } from "@/src/lib/dns-providers"; import { config } from "@/src/lib/config"; import { requireAdmin } from "@/src/lib/auth"; @@ -13,9 +14,9 @@ export default async function SettingsPage() { const modeFromEnv = isInstanceModeFromEnv(); const tokenFromEnv = isSyncTokenFromEnv(); - const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock, oauthProviders] = await Promise.all([ + const [general, dnsProvider, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock, oauthProviders] = await Promise.all([ getGeneralSettings(), - getCloudflareSettings(), + getDnsProviderSettings(), getAuthentikSettings(), getMetricsSettings(), getLoggingSettings(), @@ -26,11 +27,11 @@ export default async function SettingsPage() { listOAuthProviders(), ]); - const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] = + const [overrideGeneral, overrideDnsProvider, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] = instanceMode === "slave" ? await Promise.all([ getSetting("general"), - getSetting("cloudflare"), + getSetting("dns_provider"), getSetting("authentik"), getSetting("metrics"), getSetting("logging"), @@ -49,11 +50,8 @@ export default async function SettingsPage() { return ( ({ + name, + displayName, + description, + docsUrl, + modulePath, + fields: fields.map(({ key, label, type, placeholder, description, required }) => ({ + key, + label, + type, + placeholder, + description, + required, + })), + })); + + return NextResponse.json(providers); + } catch (error) { + return apiErrorResponse(error); + } +} diff --git a/app/api/v1/openapi.json/route.ts b/app/api/v1/openapi.json/route.ts index a37f3eb1..0660ea4c 100644 --- a/app/api/v1/openapi.json/route.ts +++ b/app/api/v1/openapi.json/route.ts @@ -784,6 +784,7 @@ const spec = { enum: [ "general", "cloudflare", + "dns-provider", "authentik", "metrics", "logging", @@ -807,6 +808,7 @@ const spec = { oneOf: [ { $ref: "#/components/schemas/GeneralSettings" }, { $ref: "#/components/schemas/CloudflareSettings" }, + { $ref: "#/components/schemas/DnsProviderSettings" }, { $ref: "#/components/schemas/AuthentikSettings" }, { $ref: "#/components/schemas/MetricsSettings" }, { $ref: "#/components/schemas/LoggingSettings" }, @@ -836,6 +838,7 @@ const spec = { enum: [ "general", "cloudflare", + "dns-provider", "authentik", "metrics", "logging", @@ -1864,6 +1867,27 @@ const spec = { }, required: ["apiToken"], }, + DnsProviderSettings: { + type: "object", + description: "DNS provider configuration for ACME DNS-01 challenges. Supports multiple configured providers with a default.", + properties: { + providers: { + type: "object", + additionalProperties: { + type: "object", + additionalProperties: { type: "string" }, + description: "Credential key-value pairs for this provider", + }, + description: "Configured providers keyed by name (e.g. { cloudflare: { api_token: '...' }, route53: { ... } })", + }, + default: { + type: "string", + nullable: true, + description: "Name of the default provider used for DNS-01 challenges (null = HTTP-01 only)", + }, + }, + required: ["providers", "default"], + }, AuthentikSettings: { type: "object", properties: { diff --git a/app/api/v1/settings/[group]/route.ts b/app/api/v1/settings/[group]/route.ts index bab37ae3..51e2b071 100644 --- a/app/api/v1/settings/[group]/route.ts +++ b/app/api/v1/settings/[group]/route.ts @@ -7,6 +7,7 @@ import { getMetricsSettings, saveMetricsSettings, getLoggingSettings, saveLoggingSettings, getDnsSettings, saveDnsSettings, + getDnsProviderSettings, saveDnsProviderSettings, getUpstreamDnsResolutionSettings, saveUpstreamDnsResolutionSettings, getGeoBlockSettings, saveGeoBlockSettings, getWafSettings, saveWafSettings, @@ -27,6 +28,7 @@ const SETTINGS_HANDLERS: Record = { metrics: { get: getMetricsSettings, save: saveMetricsSettings as (data: never) => Promise, applyCaddy: true }, logging: { get: getLoggingSettings, save: saveLoggingSettings as (data: never) => Promise, applyCaddy: true }, dns: { get: getDnsSettings, save: saveDnsSettings as (data: never) => Promise, applyCaddy: true }, + "dns-provider": { get: getDnsProviderSettings, save: saveDnsProviderSettings as (data: never) => Promise, applyCaddy: true }, "upstream-dns": { get: getUpstreamDnsResolutionSettings, save: saveUpstreamDnsResolutionSettings as (data: never) => Promise, applyCaddy: true }, geoblock: { get: getGeoBlockSettings, save: saveGeoBlockSettings as (data: never) => Promise, applyCaddy: true }, waf: { get: getWafSettings, save: saveWafSettings as (data: never) => Promise, applyCaddy: true }, diff --git a/docker/caddy/Dockerfile b/docker/caddy/Dockerfile index 3cd47eeb..b3df5506 100644 --- a/docker/caddy/Dockerfile +++ b/docker/caddy/Dockerfile @@ -9,6 +9,16 @@ RUN go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest # GOPROXY=direct bypasses the module proxy cache so the latest commit is always fetched RUN GOPROXY=direct xcaddy build \ --with github.com/caddy-dns/cloudflare \ + --with github.com/caddy-dns/route53 \ + --with github.com/caddy-dns/digitalocean \ + --with github.com/caddy-dns/duckdns \ + --with github.com/caddy-dns/hetzner \ + --with github.com/caddy-dns/vultr \ + --with github.com/caddy-dns/porkbun \ + --with github.com/caddy-dns/godaddy \ + --with github.com/caddy-dns/namecheap \ + --with github.com/caddy-dns/ovh \ + --with github.com/caddy-dns/linode \ --with github.com/mholt/caddy-l4 \ --with github.com/fuomag9/caddy-blocker-plugin \ --with github.com/corazawaf/coraza-caddy/v2 \ diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 30543a4f..45366161 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -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 = { - name: "cloudflare", - api_token: cloudflare.apiToken - }; - - const dnsChallenge: Record = { - 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 = { module: "acme" @@ -1663,23 +1669,15 @@ async function buildTlsAutomation( issuer.email = options.acmeEmail; } - if (hasCloudflare) { - const providerConfig: Record = { - name: "cloudflare", - api_token: cloudflare.apiToken - }; - - const dnsChallenge: Record = { - 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({ diff --git a/src/lib/db.ts b/src/lib/db.ts index 24f7e9fa..9fa5f004 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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); } diff --git a/src/lib/dns-providers.ts b/src/lib/dns-providers.ts new file mode 100644 index 00000000..72df070d --- /dev/null +++ b/src/lib/dns-providers.ts @@ -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; +}; + +// ─── 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 +): Record { + 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 +): Record { + 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, + dnsResolvers: string[] +): Record | null { + const def = getProviderDefinition(providerName); + if (!def) return null; + + const decrypted = decryptProviderCredentials(providerName, credentials); + + // Build provider config: { name: "cloudflare", api_token: "..." } + const providerConfig: Record = { name: providerName }; + for (const [key, value] of Object.entries(decrypted)) { + if (value) { + providerConfig[key] = value; + } + } + + const dnsChallenge: Record = { provider: providerConfig }; + if (dnsResolvers.length > 0) { + dnsChallenge.resolvers = dnsResolvers; + } + + return dnsChallenge; +} diff --git a/src/lib/instance-sync.ts b/src/lib/instance-sync.ts index 6fd72d90..cce41890 100644 --- a/src/lib/instance-sync.ts +++ b/src/lib/instance-sync.ts @@ -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 { 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); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index d156a278..5df7e296 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -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>; + /** 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 { await setSetting("dns", settings); } +export async function getDnsProviderSettings(): Promise { + const raw = await getEffectiveSetting>("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; + return { providers: { [name]: creds }, default: name }; + } + + return raw as unknown as DnsProviderSettings; +} + +export async function saveDnsProviderSettings(settings: DnsProviderSettings): Promise { + await setSetting("dns_provider", settings); +} + export async function getUpstreamDnsResolutionSettings(): Promise { return await getEffectiveSetting("upstream_dns_resolution"); } diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts index 88ddc21f..180e980c 100644 --- a/tests/e2e/settings.spec.ts +++ b/tests/e2e/settings.spec.ts @@ -9,7 +9,7 @@ test.describe('Settings', () => { test('settings page renders content', async ({ page }) => { await page.goto('/settings'); - const hasContent = await page.locator('text=/settings|general|cloudflare|dns|logging/i').count() > 0; + const hasContent = await page.locator('text=/settings|general|dns provider|dns|logging/i').count() > 0; expect(hasContent).toBe(true); }); @@ -30,9 +30,9 @@ test.describe('Settings', () => { await expect(page.getByRole('button', { name: /save general settings/i })).toBeEnabled({ timeout: 10000 }); }); - test('settings page has Cloudflare and DNS sections', async ({ page }) => { + test('settings page has DNS Provider and DNS sections', async ({ page }) => { await page.goto('/settings'); - await expect(page.getByRole('button', { name: /save cloudflare settings/i })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'DNS Providers' })).toBeVisible(); await expect(page.getByRole('button', { name: /save dns settings/i })).toBeVisible(); }); }); diff --git a/tests/integration/instance-sync.test.ts b/tests/integration/instance-sync.test.ts index 066433ee..7963b322 100644 --- a/tests/integration/instance-sync.test.ts +++ b/tests/integration/instance-sync.test.ts @@ -266,6 +266,7 @@ describe('applySyncPayload', () => { settings: { general: null, cloudflare: null, + dns_provider: null, authentik: null, metrics: null, logging: null, diff --git a/tests/unit/api-routes/settings.test.ts b/tests/unit/api-routes/settings.test.ts index f35a4664..5027fc78 100644 --- a/tests/unit/api-routes/settings.test.ts +++ b/tests/unit/api-routes/settings.test.ts @@ -13,6 +13,8 @@ vi.mock('@/src/lib/settings', () => ({ saveLoggingSettings: vi.fn(), getDnsSettings: vi.fn(), saveDnsSettings: vi.fn(), + getDnsProviderSettings: vi.fn(), + saveDnsProviderSettings: vi.fn(), getUpstreamDnsResolutionSettings: vi.fn(), saveUpstreamDnsResolutionSettings: vi.fn(), getGeoBlockSettings: vi.fn(),