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:
@@ -20,14 +20,16 @@ import type {
|
|||||||
MetricsSettings,
|
MetricsSettings,
|
||||||
LoggingSettings,
|
LoggingSettings,
|
||||||
DnsSettings,
|
DnsSettings,
|
||||||
|
DnsProviderSettings,
|
||||||
UpstreamDnsResolutionSettings,
|
UpstreamDnsResolutionSettings,
|
||||||
GeoBlockSettings,
|
GeoBlockSettings,
|
||||||
} from "@/lib/settings";
|
} from "@/lib/settings";
|
||||||
|
import type { DnsProviderDefinition } from "@/src/lib/dns-providers";
|
||||||
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
|
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
|
||||||
import OAuthProvidersSection from "./OAuthProvidersSection";
|
import OAuthProvidersSection from "./OAuthProvidersSection";
|
||||||
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
|
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
|
||||||
import {
|
import {
|
||||||
updateCloudflareSettingsAction,
|
updateDnsProviderSettingsAction,
|
||||||
updateGeneralSettingsAction,
|
updateGeneralSettingsAction,
|
||||||
updateAuthentikSettingsAction,
|
updateAuthentikSettingsAction,
|
||||||
updateMetricsSettingsAction,
|
updateMetricsSettingsAction,
|
||||||
@@ -112,7 +114,7 @@ function SettingSection({
|
|||||||
const A: Record<string, AccentConfig> = {
|
const A: Record<string, AccentConfig> = {
|
||||||
sync: { border: "border-l-violet-500", icon: "border-violet-500/30 bg-violet-500/10 text-violet-500" },
|
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" },
|
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" },
|
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" },
|
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" },
|
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<string, AccentConfig> = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
general: GeneralSettings | null;
|
general: GeneralSettings | null;
|
||||||
cloudflare: {
|
dnsProvider: DnsProviderSettings | null;
|
||||||
hasToken: boolean;
|
dnsProviderDefinitions: DnsProviderDefinition[];
|
||||||
zoneId?: string;
|
|
||||||
accountId?: string;
|
|
||||||
};
|
|
||||||
authentik: AuthentikSettings | null;
|
authentik: AuthentikSettings | null;
|
||||||
metrics: MetricsSettings | null;
|
metrics: MetricsSettings | null;
|
||||||
logging: LoggingSettings | null;
|
logging: LoggingSettings | null;
|
||||||
@@ -145,7 +144,7 @@ type Props = {
|
|||||||
tokenFromEnv: boolean;
|
tokenFromEnv: boolean;
|
||||||
overrides: {
|
overrides: {
|
||||||
general: boolean;
|
general: boolean;
|
||||||
cloudflare: boolean;
|
dnsProvider: boolean;
|
||||||
authentik: boolean;
|
authentik: boolean;
|
||||||
metrics: boolean;
|
metrics: boolean;
|
||||||
logging: boolean;
|
logging: boolean;
|
||||||
@@ -178,7 +177,8 @@ type Props = {
|
|||||||
|
|
||||||
export default function SettingsClient({
|
export default function SettingsClient({
|
||||||
general,
|
general,
|
||||||
cloudflare,
|
dnsProvider,
|
||||||
|
dnsProviderDefinitions,
|
||||||
authentik,
|
authentik,
|
||||||
metrics,
|
metrics,
|
||||||
logging,
|
logging,
|
||||||
@@ -190,7 +190,9 @@ export default function SettingsClient({
|
|||||||
instanceSync
|
instanceSync
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
|
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 [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null);
|
||||||
const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null);
|
const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null);
|
||||||
const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null);
|
const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null);
|
||||||
@@ -207,7 +209,7 @@ export default function SettingsClient({
|
|||||||
const isSlave = instanceSync.mode === "slave";
|
const isSlave = instanceSync.mode === "slave";
|
||||||
const isMaster = instanceSync.mode === "master";
|
const isMaster = instanceSync.mode === "master";
|
||||||
const [generalOverride, setGeneralOverride] = useState(instanceSync.overrides.general);
|
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 [authentikOverride, setAuthentikOverride] = useState(instanceSync.overrides.authentik);
|
||||||
const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics);
|
const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics);
|
||||||
const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging);
|
const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging);
|
||||||
@@ -463,65 +465,159 @@ export default function SettingsClient({
|
|||||||
</form>
|
</form>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
{/* ── Cloudflare DNS ── */}
|
{/* ── DNS Providers ── */}
|
||||||
<SettingSection
|
<SettingSection
|
||||||
icon={<Cloud className="h-4 w-4" />}
|
icon={<Cloud className="h-4 w-4" />}
|
||||||
title="Cloudflare DNS"
|
title="DNS Providers"
|
||||||
description="Configure a Cloudflare API token with Zone.DNS Edit permissions to enable DNS-01 challenges for wildcard certificates."
|
description="Configure DNS providers for ACME DNS-01 challenges (required for wildcard certificates). You can add multiple providers and select a default."
|
||||||
accent={A.cloudflare}
|
accent={A.dnsProvider}
|
||||||
>
|
>
|
||||||
{cloudflare.hasToken && (
|
{dnsProviderState?.message && (
|
||||||
<InfoAlert>
|
<StatusAlert message={dnsProviderState.message} success={dnsProviderState.success} />
|
||||||
A Cloudflare API token is already configured. Leave the token field blank to keep it, or select “Remove existing token” to delete it.
|
|
||||||
</InfoAlert>
|
|
||||||
)}
|
)}
|
||||||
<form action={cloudflareFormAction} className="flex flex-col gap-3">
|
{isSlave && (
|
||||||
{cloudflareState?.message && (
|
|
||||||
<StatusAlert message={cloudflareState.message} success={cloudflareState.success} />
|
|
||||||
)}
|
|
||||||
{isSlave && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="cloudflare-override"
|
|
||||||
name="overrideEnabled"
|
|
||||||
checked={cloudflareOverride}
|
|
||||||
onCheckedChange={(v) => setCloudflareOverride(!!v)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="cloudflare-override">Override master settings</Label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="cf-apiToken">API token</Label>
|
|
||||||
<Input
|
|
||||||
id="cf-apiToken"
|
|
||||||
name="apiToken"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
placeholder="Enter new token"
|
|
||||||
disabled={isSlave && !cloudflareOverride}
|
|
||||||
className="h-8 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="cf-clearToken"
|
id="dnsprovider-override"
|
||||||
name="clearToken"
|
name="overrideEnabled"
|
||||||
disabled={!cloudflare.hasToken || (isSlave && !cloudflareOverride)}
|
form="dnsp-add-form"
|
||||||
|
checked={dnsProviderOverride}
|
||||||
|
onCheckedChange={(v) => setDnsProviderOverride(!!v)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="cf-clearToken">Remove existing token</Label>
|
<Label htmlFor="dnsprovider-override">Override master settings</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
)}
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="cf-zoneId">Zone ID</Label>
|
{/* Configured providers list */}
|
||||||
<Input id="cf-zoneId" name="zoneId" defaultValue={cloudflare.zoneId ?? ""} disabled={isSlave && !cloudflareOverride} className="h-8 text-sm font-mono" />
|
{configuredProviders.length > 0 && (
|
||||||
</div>
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Configured providers</Label>
|
||||||
<Label htmlFor="cf-accountId">Account ID</Label>
|
{configuredProviders.map((name) => {
|
||||||
<Input id="cf-accountId" name="accountId" defaultValue={cloudflare.accountId ?? ""} disabled={isSlave && !cloudflareOverride} className="h-8 text-sm font-mono" />
|
const def = dnsProviderDefinitions.find((p) => p.name === name);
|
||||||
</div>
|
const isDefault = dnsProvider?.default === name;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-md border px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold">{def?.displayName ?? name}</span>
|
||||||
|
{isDefault && <StatusChip status="active" label="Default" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!isDefault && (
|
||||||
|
<form action={dnsProviderFormAction}>
|
||||||
|
<input type="hidden" name="action" value="set-default" />
|
||||||
|
<input type="hidden" name="provider" value={name} />
|
||||||
|
{isSlave && <input type="hidden" name="overrideEnabled" value={dnsProviderOverride ? "on" : ""} />}
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="text-emerald-600 border-emerald-500/50">
|
||||||
|
Set default
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<form action={dnsProviderFormAction}>
|
||||||
|
<input type="hidden" name="action" value="remove" />
|
||||||
|
<input type="hidden" name="provider" value={name} />
|
||||||
|
{isSlave && <input type="hidden" name="overrideEnabled" value={dnsProviderOverride ? "on" : ""} />}
|
||||||
|
<Button type="submit" variant="outline" size="sm" className="text-destructive border-destructive/50">
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{dnsProvider?.default && (
|
||||||
|
<form action={dnsProviderFormAction}>
|
||||||
|
<input type="hidden" name="action" value="set-default" />
|
||||||
|
<input type="hidden" name="provider" value="none" />
|
||||||
|
{isSlave && <input type="hidden" name="overrideEnabled" value={dnsProviderOverride ? "on" : ""} />}
|
||||||
|
<Button type="submit" variant="ghost" size="sm" className="text-xs text-muted-foreground">
|
||||||
|
Clear default (HTTP-01 only)
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add / update provider form */}
|
||||||
|
<form id="dnsp-add-form" action={dnsProviderFormAction} className="flex flex-col gap-3">
|
||||||
|
<input type="hidden" name="action" value="save" />
|
||||||
|
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{configuredProviders.length > 0 ? "Add or update provider" : "Add a provider"}
|
||||||
|
</Label>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="dns-provider-select">Provider</Label>
|
||||||
|
<Select
|
||||||
|
name="provider"
|
||||||
|
value={selectedProvider}
|
||||||
|
onValueChange={setSelectedProvider}
|
||||||
|
disabled={isSlave && !dnsProviderOverride}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="dns-provider-select">
|
||||||
|
<SelectValue placeholder="Select a DNS provider..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Select...</SelectItem>
|
||||||
|
{dnsProviderDefinitions.map((p) => (
|
||||||
|
<SelectItem key={p.name} value={p.name}>
|
||||||
|
{p.displayName}{configuredProviders.includes(p.name) ? " (update)" : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic credential fields */}
|
||||||
|
{selectedProvider && selectedProvider !== "none" && (() => {
|
||||||
|
const providerDef = dnsProviderDefinitions.find((p) => p.name === selectedProvider);
|
||||||
|
if (!providerDef) return null;
|
||||||
|
const isUpdate = configuredProviders.includes(selectedProvider);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{providerDef.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{providerDef.description}</p>
|
||||||
|
)}
|
||||||
|
{providerDef.fields.map((field) => (
|
||||||
|
<div key={field.key} className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={`dnsp-${field.key}`} className="text-xs">
|
||||||
|
{field.label}{field.required ? "" : " (optional)"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`dnsp-${field.key}`}
|
||||||
|
name={`credential_${field.key}`}
|
||||||
|
type={field.type === "password" ? "password" : "text"}
|
||||||
|
autoComplete={field.type === "password" ? "new-password" : "off"}
|
||||||
|
placeholder={field.placeholder ?? ""}
|
||||||
|
disabled={isSlave && !dnsProviderOverride}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
{field.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isUpdate && (
|
||||||
|
<InfoAlert>
|
||||||
|
Credentials are already configured. Leave fields blank to keep existing values.
|
||||||
|
</InfoAlert>
|
||||||
|
)}
|
||||||
|
{providerDef.docsUrl && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<a href={providerDef.docsUrl} target="_blank" rel="noopener noreferrer" className="underline">
|
||||||
|
Provider documentation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{isSlave && <input type="hidden" name="overrideEnabled" value={dnsProviderOverride ? "on" : ""} />}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" size="sm">Save Cloudflare settings</Button>
|
<Button type="submit" size="sm" disabled={!selectedProvider || selectedProvider === "none"}>
|
||||||
|
{selectedProvider && selectedProvider !== "none" && configuredProviders.includes(selectedProvider) ? "Update provider" : "Add provider"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { requireAdmin } from "@/src/lib/auth";
|
|||||||
import { applyCaddyConfig } from "@/src/lib/caddy";
|
import { applyCaddyConfig } from "@/src/lib/caddy";
|
||||||
import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync";
|
import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync";
|
||||||
import { createInstance, deleteInstance, updateInstance } from "@/src/lib/models/instances";
|
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 { listProxyHosts, updateProxyHost } from "@/src/lib/models/proxy-hosts";
|
||||||
import { getWafRuleMessages } from "@/src/lib/models/waf-events";
|
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 = {
|
type ActionResult = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -113,6 +114,122 @@ export async function updateCloudflareSettingsAction(_prevState: ActionResult |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDnsProviderSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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<ActionResult> {
|
export async function updateAuthentikSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import SettingsClient from "./SettingsClient";
|
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 { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
|
||||||
import { listInstances } from "@/src/lib/models/instances";
|
import { listInstances } from "@/src/lib/models/instances";
|
||||||
import { listOAuthProviders } from "@/src/lib/models/oauth-providers";
|
import { listOAuthProviders } from "@/src/lib/models/oauth-providers";
|
||||||
|
import { DNS_PROVIDERS } from "@/src/lib/dns-providers";
|
||||||
import { config } from "@/src/lib/config";
|
import { config } from "@/src/lib/config";
|
||||||
import { requireAdmin } from "@/src/lib/auth";
|
import { requireAdmin } from "@/src/lib/auth";
|
||||||
|
|
||||||
@@ -13,9 +14,9 @@ export default async function SettingsPage() {
|
|||||||
const modeFromEnv = isInstanceModeFromEnv();
|
const modeFromEnv = isInstanceModeFromEnv();
|
||||||
const tokenFromEnv = isSyncTokenFromEnv();
|
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(),
|
getGeneralSettings(),
|
||||||
getCloudflareSettings(),
|
getDnsProviderSettings(),
|
||||||
getAuthentikSettings(),
|
getAuthentikSettings(),
|
||||||
getMetricsSettings(),
|
getMetricsSettings(),
|
||||||
getLoggingSettings(),
|
getLoggingSettings(),
|
||||||
@@ -26,11 +27,11 @@ export default async function SettingsPage() {
|
|||||||
listOAuthProviders(),
|
listOAuthProviders(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
|
const [overrideGeneral, overrideDnsProvider, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
|
||||||
instanceMode === "slave"
|
instanceMode === "slave"
|
||||||
? await Promise.all([
|
? await Promise.all([
|
||||||
getSetting("general"),
|
getSetting("general"),
|
||||||
getSetting("cloudflare"),
|
getSetting("dns_provider"),
|
||||||
getSetting("authentik"),
|
getSetting("authentik"),
|
||||||
getSetting("metrics"),
|
getSetting("metrics"),
|
||||||
getSetting("logging"),
|
getSetting("logging"),
|
||||||
@@ -49,11 +50,8 @@ export default async function SettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<SettingsClient
|
<SettingsClient
|
||||||
general={general}
|
general={general}
|
||||||
cloudflare={{
|
dnsProvider={dnsProvider}
|
||||||
hasToken: Boolean(cloudflare?.apiToken),
|
dnsProviderDefinitions={DNS_PROVIDERS}
|
||||||
zoneId: cloudflare?.zoneId,
|
|
||||||
accountId: cloudflare?.accountId
|
|
||||||
}}
|
|
||||||
authentik={authentik}
|
authentik={authentik}
|
||||||
metrics={metrics}
|
metrics={metrics}
|
||||||
logging={logging}
|
logging={logging}
|
||||||
@@ -68,7 +66,7 @@ export default async function SettingsPage() {
|
|||||||
tokenFromEnv,
|
tokenFromEnv,
|
||||||
overrides: {
|
overrides: {
|
||||||
general: overrideGeneral !== null,
|
general: overrideGeneral !== null,
|
||||||
cloudflare: overrideCloudflare !== null,
|
dnsProvider: overrideDnsProvider !== null,
|
||||||
authentik: overrideAuthentik !== null,
|
authentik: overrideAuthentik !== null,
|
||||||
metrics: overrideMetrics !== null,
|
metrics: overrideMetrics !== null,
|
||||||
logging: overrideLogging !== null,
|
logging: overrideLogging !== null,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireApiUser, apiErrorResponse } from "@/src/lib/api-auth";
|
||||||
|
import { DNS_PROVIDERS } from "@/src/lib/dns-providers";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
await requireApiUser(request);
|
||||||
|
|
||||||
|
// Return provider definitions without any credential values
|
||||||
|
const providers = DNS_PROVIDERS.map(({ name, displayName, description, docsUrl, fields, modulePath }) => ({
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -784,6 +784,7 @@ const spec = {
|
|||||||
enum: [
|
enum: [
|
||||||
"general",
|
"general",
|
||||||
"cloudflare",
|
"cloudflare",
|
||||||
|
"dns-provider",
|
||||||
"authentik",
|
"authentik",
|
||||||
"metrics",
|
"metrics",
|
||||||
"logging",
|
"logging",
|
||||||
@@ -807,6 +808,7 @@ const spec = {
|
|||||||
oneOf: [
|
oneOf: [
|
||||||
{ $ref: "#/components/schemas/GeneralSettings" },
|
{ $ref: "#/components/schemas/GeneralSettings" },
|
||||||
{ $ref: "#/components/schemas/CloudflareSettings" },
|
{ $ref: "#/components/schemas/CloudflareSettings" },
|
||||||
|
{ $ref: "#/components/schemas/DnsProviderSettings" },
|
||||||
{ $ref: "#/components/schemas/AuthentikSettings" },
|
{ $ref: "#/components/schemas/AuthentikSettings" },
|
||||||
{ $ref: "#/components/schemas/MetricsSettings" },
|
{ $ref: "#/components/schemas/MetricsSettings" },
|
||||||
{ $ref: "#/components/schemas/LoggingSettings" },
|
{ $ref: "#/components/schemas/LoggingSettings" },
|
||||||
@@ -836,6 +838,7 @@ const spec = {
|
|||||||
enum: [
|
enum: [
|
||||||
"general",
|
"general",
|
||||||
"cloudflare",
|
"cloudflare",
|
||||||
|
"dns-provider",
|
||||||
"authentik",
|
"authentik",
|
||||||
"metrics",
|
"metrics",
|
||||||
"logging",
|
"logging",
|
||||||
@@ -1864,6 +1867,27 @@ const spec = {
|
|||||||
},
|
},
|
||||||
required: ["apiToken"],
|
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: {
|
AuthentikSettings: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getMetricsSettings, saveMetricsSettings,
|
getMetricsSettings, saveMetricsSettings,
|
||||||
getLoggingSettings, saveLoggingSettings,
|
getLoggingSettings, saveLoggingSettings,
|
||||||
getDnsSettings, saveDnsSettings,
|
getDnsSettings, saveDnsSettings,
|
||||||
|
getDnsProviderSettings, saveDnsProviderSettings,
|
||||||
getUpstreamDnsResolutionSettings, saveUpstreamDnsResolutionSettings,
|
getUpstreamDnsResolutionSettings, saveUpstreamDnsResolutionSettings,
|
||||||
getGeoBlockSettings, saveGeoBlockSettings,
|
getGeoBlockSettings, saveGeoBlockSettings,
|
||||||
getWafSettings, saveWafSettings,
|
getWafSettings, saveWafSettings,
|
||||||
@@ -27,6 +28,7 @@ const SETTINGS_HANDLERS: Record<string, SettingsHandler> = {
|
|||||||
metrics: { get: getMetricsSettings, save: saveMetricsSettings as (data: never) => Promise<void>, applyCaddy: true },
|
metrics: { get: getMetricsSettings, save: saveMetricsSettings as (data: never) => Promise<void>, applyCaddy: true },
|
||||||
logging: { get: getLoggingSettings, save: saveLoggingSettings as (data: never) => Promise<void>, applyCaddy: true },
|
logging: { get: getLoggingSettings, save: saveLoggingSettings as (data: never) => Promise<void>, applyCaddy: true },
|
||||||
dns: { get: getDnsSettings, save: saveDnsSettings as (data: never) => Promise<void>, applyCaddy: true },
|
dns: { get: getDnsSettings, save: saveDnsSettings as (data: never) => Promise<void>, applyCaddy: true },
|
||||||
|
"dns-provider": { get: getDnsProviderSettings, save: saveDnsProviderSettings as (data: never) => Promise<void>, applyCaddy: true },
|
||||||
"upstream-dns": { get: getUpstreamDnsResolutionSettings, save: saveUpstreamDnsResolutionSettings as (data: never) => Promise<void>, applyCaddy: true },
|
"upstream-dns": { get: getUpstreamDnsResolutionSettings, save: saveUpstreamDnsResolutionSettings as (data: never) => Promise<void>, applyCaddy: true },
|
||||||
geoblock: { get: getGeoBlockSettings, save: saveGeoBlockSettings as (data: never) => Promise<void>, applyCaddy: true },
|
geoblock: { get: getGeoBlockSettings, save: saveGeoBlockSettings as (data: never) => Promise<void>, applyCaddy: true },
|
||||||
waf: { get: getWafSettings, save: saveWafSettings as (data: never) => Promise<void>, applyCaddy: true },
|
waf: { get: getWafSettings, save: saveWafSettings as (data: never) => Promise<void>, applyCaddy: true },
|
||||||
|
|||||||
@@ -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
|
# GOPROXY=direct bypasses the module proxy cache so the latest commit is always fetched
|
||||||
RUN GOPROXY=direct xcaddy build \
|
RUN GOPROXY=direct xcaddy build \
|
||||||
--with github.com/caddy-dns/cloudflare \
|
--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/mholt/caddy-l4 \
|
||||||
--with github.com/fuomag9/caddy-blocker-plugin \
|
--with github.com/fuomag9/caddy-blocker-plugin \
|
||||||
--with github.com/corazawaf/coraza-caddy/v2 \
|
--with github.com/corazawaf/coraza-caddy/v2 \
|
||||||
|
|||||||
+33
-35
@@ -26,11 +26,11 @@ import db, { nowIso } from "./db";
|
|||||||
import { eq, isNull } from "drizzle-orm";
|
import { eq, isNull } from "drizzle-orm";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
import {
|
import {
|
||||||
getCloudflareSettings,
|
|
||||||
getGeneralSettings,
|
getGeneralSettings,
|
||||||
getMetricsSettings,
|
getMetricsSettings,
|
||||||
getLoggingSettings,
|
getLoggingSettings,
|
||||||
getDnsSettings,
|
getDnsSettings,
|
||||||
|
getDnsProviderSettings,
|
||||||
getUpstreamDnsResolutionSettings,
|
getUpstreamDnsResolutionSettings,
|
||||||
getGeoBlockSettings,
|
getGeoBlockSettings,
|
||||||
getWafSettings,
|
getWafSettings,
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
type GeoBlockSettings,
|
type GeoBlockSettings,
|
||||||
type WafSettings
|
type WafSettings
|
||||||
} from "./settings";
|
} from "./settings";
|
||||||
|
import { buildDnsChallengeConfig, type DnsProviderCredentials } from "./dns-providers";
|
||||||
import { syncInstances } from "./instance-sync";
|
import { syncInstances } from "./instance-sync";
|
||||||
import {
|
import {
|
||||||
accessListEntries,
|
accessListEntries,
|
||||||
@@ -1590,8 +1591,11 @@ async function buildTlsAutomation(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cloudflare = await getCloudflareSettings();
|
const dnsProviderSettings = await getDnsProviderSettings();
|
||||||
const hasCloudflare = cloudflare && cloudflare.apiToken;
|
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 dnsSettings = options.dnsSettings ?? await getDnsSettings();
|
||||||
const hasDnsResolvers = dnsSettings && dnsSettings.enabled && dnsSettings.resolvers && dnsSettings.resolvers.length > 0;
|
const hasDnsResolvers = dnsSettings && dnsSettings.enabled && dnsSettings.resolvers && dnsSettings.resolvers.length > 0;
|
||||||
@@ -1619,23 +1623,15 @@ async function buildTlsAutomation(
|
|||||||
issuer.email = options.acmeEmail;
|
issuer.email = options.acmeEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasCloudflare) {
|
if (globalDnsProvider) {
|
||||||
const providerConfig: Record<string, string> = {
|
const dnsChallenge = buildDnsChallengeConfig(
|
||||||
name: "cloudflare",
|
globalDnsProvider.provider,
|
||||||
api_token: cloudflare.apiToken
|
globalDnsProvider.credentials,
|
||||||
};
|
dnsResolvers
|
||||||
|
);
|
||||||
const dnsChallenge: Record<string, unknown> = {
|
if (dnsChallenge) {
|
||||||
provider: providerConfig
|
issuer.challenges = { dns: dnsChallenge };
|
||||||
};
|
|
||||||
|
|
||||||
if (dnsResolvers.length > 0) {
|
|
||||||
dnsChallenge.resolvers = dnsResolvers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer.challenges = {
|
|
||||||
dns: dnsChallenge
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
policies.push({
|
policies.push({
|
||||||
@@ -1654,6 +1650,16 @@ async function buildTlsAutomation(
|
|||||||
|
|
||||||
managedCertificateIds.add(entry.certificate.id);
|
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)) {
|
for (const subjectGroup of groupHostPatternsByPriority(subjects)) {
|
||||||
const issuer: Record<string, unknown> = {
|
const issuer: Record<string, unknown> = {
|
||||||
module: "acme"
|
module: "acme"
|
||||||
@@ -1663,23 +1669,15 @@ async function buildTlsAutomation(
|
|||||||
issuer.email = options.acmeEmail;
|
issuer.email = options.acmeEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasCloudflare) {
|
if (effectiveProvider) {
|
||||||
const providerConfig: Record<string, string> = {
|
const dnsChallenge = buildDnsChallengeConfig(
|
||||||
name: "cloudflare",
|
effectiveProvider.provider,
|
||||||
api_token: cloudflare.apiToken
|
effectiveProvider.credentials,
|
||||||
};
|
dnsResolvers
|
||||||
|
);
|
||||||
const dnsChallenge: Record<string, unknown> = {
|
if (dnsChallenge) {
|
||||||
provider: providerConfig
|
issuer.challenges = { dns: dnsChallenge };
|
||||||
};
|
|
||||||
|
|
||||||
if (dnsResolvers.length > 0) {
|
|
||||||
dnsChallenge.resolvers = dnsResolvers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
issuer.challenges = {
|
|
||||||
dns: dnsChallenge
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
policies.push({
|
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 {
|
try {
|
||||||
runBetterAuthDataMigration();
|
runBetterAuthDataMigration();
|
||||||
runEnvProviderSync();
|
runEnvProviderSync();
|
||||||
|
runCloudflareToProviderMigration();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Better Auth data migration warning:", 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 = {
|
export type SyncSettings = {
|
||||||
general: unknown | null;
|
general: unknown | null;
|
||||||
cloudflare: unknown | null;
|
cloudflare: unknown | null;
|
||||||
|
dns_provider: unknown | null;
|
||||||
authentik: unknown | null;
|
authentik: unknown | null;
|
||||||
metrics: unknown | null;
|
metrics: unknown | null;
|
||||||
logging: unknown | null;
|
logging: unknown | null;
|
||||||
@@ -249,6 +250,7 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
|
|||||||
const settings = {
|
const settings = {
|
||||||
general: await getSetting("general"),
|
general: await getSetting("general"),
|
||||||
cloudflare: await getSetting("cloudflare"),
|
cloudflare: await getSetting("cloudflare"),
|
||||||
|
dns_provider: await getSetting("dns_provider"),
|
||||||
authentik: await getSetting("authentik"),
|
authentik: await getSetting("authentik"),
|
||||||
metrics: await getSetting("metrics"),
|
metrics: await getSetting("metrics"),
|
||||||
logging: await getSetting("logging"),
|
logging: await getSetting("logging"),
|
||||||
@@ -422,6 +424,7 @@ export async function syncInstances(): Promise<{ total: number; success: number;
|
|||||||
export async function applySyncPayload(payload: SyncPayload) {
|
export async function applySyncPayload(payload: SyncPayload) {
|
||||||
await setSyncedSetting("general", payload.settings.general);
|
await setSyncedSetting("general", payload.settings.general);
|
||||||
await setSyncedSetting("cloudflare", payload.settings.cloudflare);
|
await setSyncedSetting("cloudflare", payload.settings.cloudflare);
|
||||||
|
await setSyncedSetting("dns_provider", payload.settings.dns_provider ?? null);
|
||||||
await setSyncedSetting("authentik", payload.settings.authentik);
|
await setSyncedSetting("authentik", payload.settings.authentik);
|
||||||
await setSyncedSetting("metrics", payload.settings.metrics);
|
await setSyncedSetting("metrics", payload.settings.metrics);
|
||||||
await setSyncedSetting("logging", payload.settings.logging);
|
await setSyncedSetting("logging", payload.settings.logging);
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ export type DnsSettings = {
|
|||||||
timeout?: string; // DNS query timeout (e.g., "5s")
|
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 UpstreamDnsAddressFamily = "ipv6" | "ipv4" | "both";
|
||||||
|
|
||||||
export type UpstreamDnsResolutionSettings = {
|
export type UpstreamDnsResolutionSettings = {
|
||||||
@@ -195,6 +202,25 @@ export async function saveDnsSettings(settings: DnsSettings): Promise<void> {
|
|||||||
await setSetting("dns", settings);
|
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> {
|
export async function getUpstreamDnsResolutionSettings(): Promise<UpstreamDnsResolutionSettings | null> {
|
||||||
return await getEffectiveSetting<UpstreamDnsResolutionSettings>("upstream_dns_resolution");
|
return await getEffectiveSetting<UpstreamDnsResolutionSettings>("upstream_dns_resolution");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ test.describe('Settings', () => {
|
|||||||
|
|
||||||
test('settings page renders content', async ({ page }) => {
|
test('settings page renders content', async ({ page }) => {
|
||||||
await page.goto('/settings');
|
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);
|
expect(hasContent).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,9 +30,9 @@ test.describe('Settings', () => {
|
|||||||
await expect(page.getByRole('button', { name: /save general settings/i })).toBeEnabled({ timeout: 10000 });
|
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 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();
|
await expect(page.getByRole('button', { name: /save dns settings/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ describe('applySyncPayload', () => {
|
|||||||
settings: {
|
settings: {
|
||||||
general: null,
|
general: null,
|
||||||
cloudflare: null,
|
cloudflare: null,
|
||||||
|
dns_provider: null,
|
||||||
authentik: null,
|
authentik: null,
|
||||||
metrics: null,
|
metrics: null,
|
||||||
logging: null,
|
logging: null,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ vi.mock('@/src/lib/settings', () => ({
|
|||||||
saveLoggingSettings: vi.fn(),
|
saveLoggingSettings: vi.fn(),
|
||||||
getDnsSettings: vi.fn(),
|
getDnsSettings: vi.fn(),
|
||||||
saveDnsSettings: vi.fn(),
|
saveDnsSettings: vi.fn(),
|
||||||
|
getDnsProviderSettings: vi.fn(),
|
||||||
|
saveDnsProviderSettings: vi.fn(),
|
||||||
getUpstreamDnsResolutionSettings: vi.fn(),
|
getUpstreamDnsResolutionSettings: vi.fn(),
|
||||||
saveUpstreamDnsResolutionSettings: vi.fn(),
|
saveUpstreamDnsResolutionSettings: vi.fn(),
|
||||||
getGeoBlockSettings: vi.fn(),
|
getGeoBlockSettings: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user