diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts
index 4057fe34..1ec3e10d 100644
--- a/app/(dashboard)/proxy-hosts/actions.ts
+++ b/app/(dashboard)/proxy-hosts/actions.ts
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/src/lib/auth";
import { actionError, actionSuccess, INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions";
-import { createProxyHost, deleteProxyHost, updateProxyHost, type ProxyHostAuthentikInput, type LoadBalancerInput, type LoadBalancingPolicy } from "@/src/lib/models/proxy-hosts";
+import { createProxyHost, deleteProxyHost, updateProxyHost, type ProxyHostAuthentikInput, type LoadBalancerInput, type LoadBalancingPolicy, type DnsResolverInput } from "@/src/lib/models/proxy-hosts";
import { getCertificate } from "@/src/lib/models/certificates";
import { getCloudflareSettings } from "@/src/lib/settings";
@@ -270,6 +270,62 @@ function parseLoadBalancerConfig(formData: FormData): LoadBalancerInput | undefi
return Object.keys(result).length > 0 ? result : undefined;
}
+function parseDnsResolverConfig(formData: FormData): DnsResolverInput | undefined {
+ if (!formData.has("dns_present")) {
+ return undefined;
+ }
+
+ const enabledIndicator = formData.has("dns_enabled_present");
+ const enabledValue = enabledIndicator
+ ? formData.has("dns_enabled")
+ ? parseCheckbox(formData.get("dns_enabled"))
+ : false
+ : undefined;
+
+ // Parse resolvers from newline-separated input
+ const resolversRaw = parseOptionalText(formData.get("dns_resolvers"));
+ let resolvers: string[] | undefined = undefined;
+ if (resolversRaw || formData.has("dns_resolvers")) {
+ resolvers = resolversRaw
+ ? resolversRaw
+ .split(/[\n,]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0)
+ : [];
+ }
+
+ // Parse fallbacks from newline-separated input
+ const fallbacksRaw = parseOptionalText(formData.get("dns_fallbacks"));
+ let fallbacks: string[] | null = null;
+ if (fallbacksRaw) {
+ fallbacks = fallbacksRaw
+ .split(/[\n,]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ if (fallbacks.length === 0) {
+ fallbacks = null;
+ }
+ }
+
+ const timeout = parseOptionalText(formData.get("dns_timeout"));
+
+ const result: DnsResolverInput = {};
+ if (enabledValue !== undefined) {
+ result.enabled = enabledValue;
+ }
+ if (resolvers !== undefined) {
+ result.resolvers = resolvers;
+ }
+ if (fallbacks !== null) {
+ result.fallbacks = fallbacks;
+ }
+ if (timeout !== null) {
+ result.timeout = timeout;
+ }
+
+ return Object.keys(result).length > 0 ? result : undefined;
+}
+
export async function createProxyHostAction(
_prevState: ActionState = INITIAL_ACTION_STATE,
formData: FormData
@@ -305,7 +361,8 @@ export async function createProxyHostAction(
custom_pre_handlers_json: parseOptionalText(formData.get("custom_pre_handlers_json")),
custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")),
authentik: parseAuthentikConfig(formData),
- load_balancer: parseLoadBalancerConfig(formData)
+ load_balancer: parseLoadBalancerConfig(formData),
+ dns_resolver: parseDnsResolverConfig(formData)
},
userId
);
@@ -373,7 +430,8 @@ export async function updateProxyHostAction(
? parseOptionalText(formData.get("custom_reverse_proxy_json"))
: undefined,
authentik: parseAuthentikConfig(formData),
- load_balancer: parseLoadBalancerConfig(formData)
+ load_balancer: parseLoadBalancerConfig(formData),
+ dns_resolver: parseDnsResolverConfig(formData)
},
userId
);
diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx
index 24c27591..6d7b075a 100644
--- a/app/(dashboard)/settings/SettingsClient.tsx
+++ b/app/(dashboard)/settings/SettingsClient.tsx
@@ -2,13 +2,14 @@
import { useFormState } from "react-dom";
import { Alert, Box, Button, Card, CardContent, Checkbox, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
-import type { GeneralSettings, AuthentikSettings, MetricsSettings, LoggingSettings } from "@/src/lib/settings";
+import type { GeneralSettings, AuthentikSettings, MetricsSettings, LoggingSettings, DnsSettings } from "@/src/lib/settings";
import {
updateCloudflareSettingsAction,
updateGeneralSettingsAction,
updateAuthentikSettingsAction,
updateMetricsSettingsAction,
- updateLoggingSettingsAction
+ updateLoggingSettingsAction,
+ updateDnsSettingsAction
} from "./actions";
type Props = {
@@ -21,14 +22,16 @@ type Props = {
authentik: AuthentikSettings | null;
metrics: MetricsSettings | null;
logging: LoggingSettings | null;
+ dns: DnsSettings | null;
};
-export default function SettingsClient({ general, cloudflare, authentik, metrics, logging }: Props) {
+export default function SettingsClient({ general, cloudflare, authentik, metrics, logging, dns }: Props) {
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null);
const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null);
const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null);
const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null);
+ const [dnsState, dnsFormAction] = useFormState(updateDnsSettingsAction, null);
return (
@@ -116,6 +119,65 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
+
+
+
+ DNS Resolvers
+
+
+ Configure custom DNS resolvers for ACME DNS-01 challenges. These resolvers will be used to verify DNS records during certificate issuance.
+
+
+ {dnsState?.message && (
+
+ {dnsState.message}
+
+ )}
+ }
+ label="Enable custom DNS resolvers"
+ />
+
+
+
+
+ Custom DNS resolvers are useful when your DNS provider has slow propagation or when using split-horizon DNS.
+ Common public resolvers: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9).
+
+
+
+
+
+
+
+
diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts
index 5c5d9af6..1febdad5 100644
--- a/app/(dashboard)/settings/actions.ts
+++ b/app/(dashboard)/settings/actions.ts
@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/src/lib/auth";
import { applyCaddyConfig } from "@/src/lib/caddy";
-import { getCloudflareSettings, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings } from "@/src/lib/settings";
+import { getCloudflareSettings, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings } from "@/src/lib/settings";
type ActionResult = {
success: boolean;
@@ -154,3 +154,53 @@ export async function updateLoggingSettingsAction(_prevState: ActionResult | nul
return { success: false, message: error instanceof Error ? error.message : "Failed to save logging settings" };
}
}
+
+function parseResolverList(value: string | null): string[] {
+ if (!value) return [];
+ return value
+ .split(/[,\n]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+}
+
+export async function updateDnsSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise {
+ try {
+ await requireAdmin();
+ const enabled = formData.get("enabled") === "on";
+ const resolversRaw = formData.get("resolvers") ? String(formData.get("resolvers")) : "";
+ const fallbacksRaw = formData.get("fallbacks") ? String(formData.get("fallbacks")) : "";
+ const timeout = formData.get("timeout") ? String(formData.get("timeout")).trim() : undefined;
+
+ const resolvers = parseResolverList(resolversRaw);
+ const fallbacks = parseResolverList(fallbacksRaw);
+
+ if (enabled && resolvers.length === 0) {
+ return { success: false, message: "At least one DNS resolver is required when enabled" };
+ }
+
+ await saveDnsSettings({
+ enabled,
+ resolvers,
+ fallbacks: fallbacks.length > 0 ? fallbacks : undefined,
+ timeout: timeout && timeout.length > 0 ? timeout : undefined
+ });
+
+ // Apply config to use new DNS resolvers
+ try {
+ await applyCaddyConfig();
+ revalidatePath("/settings");
+ return { success: true, message: "DNS settings saved and applied successfully" };
+ } 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 settings:", error);
+ return { success: false, message: error instanceof Error ? error.message : "Failed to save DNS settings" };
+ }
+}
diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx
index 82ba89dc..e8b871d4 100644
--- a/app/(dashboard)/settings/page.tsx
+++ b/app/(dashboard)/settings/page.tsx
@@ -1,16 +1,17 @@
import SettingsClient from "./SettingsClient";
-import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings } from "@/src/lib/settings";
+import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings } from "@/src/lib/settings";
import { requireAdmin } from "@/src/lib/auth";
export default async function SettingsPage() {
await requireAdmin();
- const [general, cloudflare, authentik, metrics, logging] = await Promise.all([
+ const [general, cloudflare, authentik, metrics, logging, dns] = await Promise.all([
getGeneralSettings(),
getCloudflareSettings(),
getAuthentikSettings(),
getMetricsSettings(),
- getLoggingSettings()
+ getLoggingSettings(),
+ getDnsSettings()
]);
return (
@@ -24,6 +25,7 @@ export default async function SettingsPage() {
authentik={authentik}
metrics={metrics}
logging={logging}
+ dns={dns}
/>
);
}
diff --git a/src/components/proxy-hosts/DnsResolverFields.tsx b/src/components/proxy-hosts/DnsResolverFields.tsx
new file mode 100644
index 00000000..82ef9c5f
--- /dev/null
+++ b/src/components/proxy-hosts/DnsResolverFields.tsx
@@ -0,0 +1,85 @@
+import { Box, Collapse, Stack, Switch, TextField, Typography, Alert } from "@mui/material";
+import { useState } from "react";
+import { ProxyHost } from "@/src/lib/models/proxy-hosts";
+
+export function DnsResolverFields({
+ dnsResolver
+}: {
+ dnsResolver?: ProxyHost["dns_resolver"] | null;
+}) {
+ const initial = dnsResolver ?? null;
+ const [enabled, setEnabled] = useState(initial?.enabled ?? false);
+
+ return (
+
+
+
+
+
+
+
+ Custom DNS Resolvers
+
+
+ Configure per-host DNS resolution for upstream discovery and health checks
+
+
+ setEnabled(checked)}
+ />
+
+
+
+
+
+
+
+
+ Per-host DNS resolvers override global settings for this specific proxy host.
+ Useful for upstream services that require specific DNS resolution (e.g., internal DNS, service discovery).
+ Common resolvers: 1.1.1.1 (Cloudflare), 8.8.8.8 (Google), 9.9.9.9 (Quad9).
+
+
+
+
+
+ );
+}
diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx
index f6c3259d..ff4ffdef 100644
--- a/src/components/proxy-hosts/HostDialogs.tsx
+++ b/src/components/proxy-hosts/HostDialogs.tsx
@@ -14,6 +14,7 @@ import { ProxyHost } from "@/src/lib/models/proxy-hosts";
import { AuthentikSettings } from "@/src/lib/settings";
import { AppDialog } from "@/src/components/ui/AppDialog";
import { AuthentikFields } from "./AuthentikFields";
+import { DnsResolverFields } from "./DnsResolverFields";
import { LoadBalancerFields } from "./LoadBalancerFields";
import { SettingsToggles } from "./SettingsToggles";
import { UpstreamInput } from "./UpstreamInput";
@@ -122,6 +123,7 @@ export function CreateHostDialog({
/>
+
);
@@ -217,6 +219,7 @@ export function EditHostDialog({
/>
+
);
diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts
index 09eb325f..bd3c459a 100644
--- a/src/lib/caddy.ts
+++ b/src/lib/caddy.ts
@@ -3,7 +3,7 @@ import { join } from "node:path";
import crypto from "node:crypto";
import db, { nowIso } from "./db";
import { config } from "./config";
-import { getCloudflareSettings, getGeneralSettings, getMetricsSettings, getLoggingSettings, setSetting } from "./settings";
+import { getCloudflareSettings, getGeneralSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, setSetting } from "./settings";
import {
accessListEntries,
certificates,
@@ -49,11 +49,19 @@ type ProxyHostRow = {
enabled: number;
};
+type DnsResolverMeta = {
+ enabled?: boolean;
+ resolvers?: string[];
+ fallbacks?: string[];
+ timeout?: string;
+};
+
type ProxyHostMeta = {
custom_reverse_proxy_json?: string;
custom_pre_handlers_json?: string;
authentik?: ProxyHostAuthentikMeta;
load_balancer?: LoadBalancerMeta;
+ dns_resolver?: DnsResolverMeta;
};
type ProxyHostAuthentikMeta = {
@@ -473,17 +481,27 @@ function buildProxyRoutes(
// Configure load balancing and health checks
const lbConfig = parseLoadBalancerConfig(meta.load_balancer);
+ const dnsConfig = parseDnsResolverConfig(meta.dns_resolver);
+
if (lbConfig) {
const loadBalancing = buildLoadBalancingConfig(lbConfig);
if (loadBalancing) {
reverseProxyHandler.load_balancing = loadBalancing;
}
- const healthChecks = buildHealthChecksConfig(lbConfig);
+ const healthChecks = buildHealthChecksConfig(lbConfig, dnsConfig);
if (healthChecks) {
reverseProxyHandler.health_checks = healthChecks;
}
}
+ // Add transport-level DNS resolver config if enabled
+ if (dnsConfig) {
+ const transportConfig = buildTransportResolverConfig(dnsConfig);
+ if (transportConfig) {
+ reverseProxyHandler.transport = transportConfig;
+ }
+ }
+
const customReverseProxy = parseOptionalJson(meta.custom_reverse_proxy_json);
if (customReverseProxy) {
if (isPlainObject(customReverseProxy)) {
@@ -785,6 +803,18 @@ async function buildTlsAutomation(
const cloudflare = await getCloudflareSettings();
const hasCloudflare = cloudflare && cloudflare.apiToken;
+ const dnsSettings = await getDnsSettings();
+ const hasDnsResolvers = dnsSettings && dnsSettings.enabled && dnsSettings.resolvers && dnsSettings.resolvers.length > 0;
+
+ // Build DNS resolvers list (primary + fallbacks)
+ const dnsResolvers: string[] = [];
+ if (hasDnsResolvers) {
+ dnsResolvers.push(...dnsSettings.resolvers);
+ if (dnsSettings.fallbacks && dnsSettings.fallbacks.length > 0) {
+ dnsResolvers.push(...dnsSettings.fallbacks);
+ }
+ }
+
const managedCertificateIds = new Set();
const policies: Record[] = [];
@@ -808,10 +838,17 @@ async function buildTlsAutomation(
api_token: cloudflare.apiToken
};
+ const dnsChallenge: Record = {
+ provider: providerConfig
+ };
+
+ // Add custom DNS resolvers if configured
+ if (dnsResolvers.length > 0) {
+ dnsChallenge.resolvers = dnsResolvers;
+ }
+
issuer.challenges = {
- dns: {
- provider: providerConfig
- }
+ dns: dnsChallenge
};
}
@@ -846,10 +883,17 @@ async function buildTlsAutomation(
api_token: cloudflare.apiToken
};
+ const dnsChallenge: Record = {
+ provider: providerConfig
+ };
+
+ // Add custom DNS resolvers if configured
+ if (dnsResolvers.length > 0) {
+ dnsChallenge.resolvers = dnsResolvers;
+ }
+
issuer.challenges = {
- dns: {
- provider: providerConfig
- }
+ dns: dnsChallenge
};
}
@@ -1272,7 +1316,14 @@ function buildLoadBalancingConfig(config: LoadBalancerRouteConfig): Record 0 ? loadBalancing : null;
}
-function buildHealthChecksConfig(config: LoadBalancerRouteConfig): Record | null {
+type DnsResolverRouteConfig = {
+ enabled: boolean;
+ resolvers: string[];
+ fallbacks: string[] | null;
+ timeout: string | null;
+};
+
+function buildHealthChecksConfig(config: LoadBalancerRouteConfig, dnsConfig: DnsResolverRouteConfig | null): Record | null {
const healthChecks: Record = {};
// Active health checks
@@ -1327,3 +1378,62 @@ function buildHealthChecksConfig(config: LoadBalancerRouteConfig): Record 0 ? healthChecks : null;
}
+
+function parseDnsResolverConfig(meta: DnsResolverMeta | undefined | null): DnsResolverRouteConfig | null {
+ if (!meta || !meta.enabled) {
+ return null;
+ }
+
+ const resolvers = Array.isArray(meta.resolvers)
+ ? meta.resolvers.map((r) => (typeof r === "string" ? r.trim() : "")).filter((r) => r.length > 0)
+ : [];
+
+ if (resolvers.length === 0) {
+ return null;
+ }
+
+ const fallbacks = Array.isArray(meta.fallbacks)
+ ? meta.fallbacks.map((r) => (typeof r === "string" ? r.trim() : "")).filter((r) => r.length > 0)
+ : null;
+
+ const timeout = typeof meta.timeout === "string" ? meta.timeout.trim() || null : null;
+
+ return {
+ enabled: true,
+ resolvers,
+ fallbacks: fallbacks && fallbacks.length > 0 ? fallbacks : null,
+ timeout
+ };
+}
+
+function buildTransportResolverConfig(dnsConfig: DnsResolverRouteConfig): Record | null {
+ if (!dnsConfig || !dnsConfig.enabled || dnsConfig.resolvers.length === 0) {
+ return null;
+ }
+
+ // Build resolver addresses list (primary + fallbacks)
+ // DNS resolvers need port, default to :53 if not specified
+ const formatResolver = (r: string) => {
+ if (r.includes(":")) return r;
+ return `${r}:53`;
+ };
+
+ const addresses = dnsConfig.resolvers.map(formatResolver);
+ if (dnsConfig.fallbacks && dnsConfig.fallbacks.length > 0) {
+ addresses.push(...dnsConfig.fallbacks.map(formatResolver));
+ }
+
+ const transport: Record = {
+ protocol: "http",
+ resolver: {
+ addresses
+ }
+ };
+
+ // Add dial timeout if specified
+ if (dnsConfig.timeout) {
+ transport.dial_timeout = dnsConfig.timeout;
+ }
+
+ return transport;
+}
diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts
index f455be80..4e6d901c 100644
--- a/src/lib/models/proxy-hosts.ts
+++ b/src/lib/models/proxy-hosts.ts
@@ -113,6 +113,28 @@ type LoadBalancerMeta = {
passive_health_check?: LoadBalancerPassiveHealthCheckMeta;
};
+// DNS Resolver Types
+export type DnsResolverConfig = {
+ enabled: boolean;
+ resolvers: string[];
+ fallbacks: string[] | null;
+ timeout: string | null;
+};
+
+export type DnsResolverInput = {
+ enabled?: boolean;
+ resolvers?: string[];
+ fallbacks?: string[] | null;
+ timeout?: string | null;
+};
+
+type DnsResolverMeta = {
+ enabled?: boolean;
+ resolvers?: string[];
+ fallbacks?: string[];
+ timeout?: string;
+};
+
export type ProxyHostAuthentikConfig = {
enabled: boolean;
outpostDomain: string | null;
@@ -151,6 +173,7 @@ type ProxyHostMeta = {
custom_pre_handlers_json?: string;
authentik?: ProxyHostAuthentikMeta;
load_balancer?: LoadBalancerMeta;
+ dns_resolver?: DnsResolverMeta;
};
export type ProxyHost = {
@@ -173,6 +196,7 @@ export type ProxyHost = {
custom_pre_handlers_json: string | null;
authentik: ProxyHostAuthentikConfig | null;
load_balancer: LoadBalancerConfig | null;
+ dns_resolver: DnsResolverConfig | null;
};
export type ProxyHostInput = {
@@ -192,6 +216,7 @@ export type ProxyHostInput = {
custom_pre_handlers_json?: string | null;
authentik?: ProxyHostAuthentikInput | null;
load_balancer?: LoadBalancerInput | null;
+ dns_resolver?: DnsResolverInput | null;
};
type ProxyHostRow = typeof proxyHosts.$inferSelect;
@@ -366,6 +391,43 @@ function sanitizeLoadBalancerMeta(meta: LoadBalancerMeta | undefined): LoadBalan
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
+function sanitizeDnsResolverMeta(meta: DnsResolverMeta | undefined): DnsResolverMeta | undefined {
+ if (!meta) {
+ return undefined;
+ }
+
+ const normalized: DnsResolverMeta = {};
+
+ if (meta.enabled !== undefined) {
+ normalized.enabled = Boolean(meta.enabled);
+ }
+
+ if (Array.isArray(meta.resolvers)) {
+ const resolvers = meta.resolvers
+ .map((r) => (typeof r === "string" ? r.trim() : ""))
+ .filter((r) => r.length > 0);
+ if (resolvers.length > 0) {
+ normalized.resolvers = resolvers;
+ }
+ }
+
+ if (Array.isArray(meta.fallbacks)) {
+ const fallbacks = meta.fallbacks
+ .map((r) => (typeof r === "string" ? r.trim() : ""))
+ .filter((r) => r.length > 0);
+ if (fallbacks.length > 0) {
+ normalized.fallbacks = fallbacks;
+ }
+ }
+
+ const timeout = normalizeMetaValue(meta.timeout ?? null);
+ if (timeout) {
+ normalized.timeout = timeout;
+ }
+
+ return Object.keys(normalized).length > 0 ? normalized : undefined;
+}
+
function serializeMeta(meta: ProxyHostMeta | null | undefined) {
if (!meta) {
return null;
@@ -391,6 +453,11 @@ function serializeMeta(meta: ProxyHostMeta | null | undefined) {
normalized.load_balancer = loadBalancer;
}
+ const dnsResolver = sanitizeDnsResolverMeta(meta.dns_resolver);
+ if (dnsResolver) {
+ normalized.dns_resolver = dnsResolver;
+ }
+
return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null;
}
@@ -404,7 +471,8 @@ function parseMeta(value: string | null): ProxyHostMeta {
custom_reverse_proxy_json: normalizeMetaValue(parsed.custom_reverse_proxy_json ?? null) ?? undefined,
custom_pre_handlers_json: normalizeMetaValue(parsed.custom_pre_handlers_json ?? null) ?? undefined,
authentik: sanitizeAuthentikMeta(parsed.authentik),
- load_balancer: sanitizeLoadBalancerMeta(parsed.load_balancer)
+ load_balancer: sanitizeLoadBalancerMeta(parsed.load_balancer),
+ dns_resolver: sanitizeDnsResolverMeta(parsed.dns_resolver)
};
} catch (error) {
console.warn("Failed to parse proxy host meta", error);
@@ -698,6 +766,65 @@ function normalizeLoadBalancerInput(
return Object.keys(next).length > 0 ? next : undefined;
}
+function normalizeDnsResolverInput(
+ input: DnsResolverInput | null | undefined,
+ existing: DnsResolverMeta | undefined
+): DnsResolverMeta | undefined {
+ if (input === undefined) {
+ return existing;
+ }
+ if (input === null) {
+ return undefined;
+ }
+
+ const next: DnsResolverMeta = { ...(existing ?? {}) };
+
+ if (input.enabled !== undefined) {
+ next.enabled = Boolean(input.enabled);
+ }
+
+ if (input.resolvers !== undefined) {
+ if (Array.isArray(input.resolvers)) {
+ const resolvers = input.resolvers
+ .map((r) => (typeof r === "string" ? r.trim() : ""))
+ .filter((r) => r.length > 0);
+ if (resolvers.length > 0) {
+ next.resolvers = resolvers;
+ } else {
+ delete next.resolvers;
+ }
+ } else {
+ delete next.resolvers;
+ }
+ }
+
+ if (input.fallbacks !== undefined) {
+ if (Array.isArray(input.fallbacks)) {
+ const fallbacks = input.fallbacks
+ .map((r) => (typeof r === "string" ? r.trim() : ""))
+ .filter((r) => r.length > 0);
+ if (fallbacks.length > 0) {
+ next.fallbacks = fallbacks;
+ } else {
+ delete next.fallbacks;
+ }
+ } else {
+ delete next.fallbacks;
+ }
+ }
+
+ if (input.timeout !== undefined) {
+ const val = normalizeMetaValue(input.timeout ?? null);
+ if (val) {
+ next.timeout = val;
+ } else {
+ delete next.timeout;
+ }
+ }
+
+ return Object.keys(next).length > 0 ? next : undefined;
+}
+
function buildMeta(existing: ProxyHostMeta, input: Partial): string | null {
const next: ProxyHostMeta = { ...existing };
@@ -737,6 +864,15 @@ function buildMeta(existing: ProxyHostMeta, input: Partial): str
}
}
+ if (input.dns_resolver !== undefined) {
+ const dnsResolver = normalizeDnsResolverInput(input.dns_resolver, existing.dns_resolver);
+ if (dnsResolver) {
+ next.dns_resolver = dnsResolver;
+ } else {
+ delete next.dns_resolver;
+ }
+ }
+
return serializeMeta(next);
}
@@ -948,6 +1084,53 @@ function dehydrateLoadBalancer(config: LoadBalancerConfig | null): LoadBalancerM
return meta;
}
+function hydrateDnsResolver(meta: DnsResolverMeta | undefined): DnsResolverConfig | null {
+ if (!meta) {
+ return null;
+ }
+
+ const enabled = Boolean(meta.enabled);
+
+ const resolvers = Array.isArray(meta.resolvers)
+ ? meta.resolvers.map((r) => (typeof r === "string" ? r.trim() : "")).filter((r) => r.length > 0)
+ : [];
+
+ const fallbacks = Array.isArray(meta.fallbacks)
+ ? meta.fallbacks.map((r) => (typeof r === "string" ? r.trim() : "")).filter((r) => r.length > 0)
+ : null;
+
+ const timeout = normalizeMetaValue(meta.timeout ?? null);
+
+ return {
+ enabled,
+ resolvers,
+ fallbacks: fallbacks && fallbacks.length > 0 ? fallbacks : null,
+ timeout
+ };
+}
+
+function dehydrateDnsResolver(config: DnsResolverConfig | null): DnsResolverMeta | undefined {
+ if (!config) {
+ return undefined;
+ }
+
+ const meta: DnsResolverMeta = {
+ enabled: config.enabled
+ };
+
+ if (config.resolvers && config.resolvers.length > 0) {
+ meta.resolvers = [...config.resolvers];
+ }
+ if (config.fallbacks && config.fallbacks.length > 0) {
+ meta.fallbacks = [...config.fallbacks];
+ }
+ if (config.timeout) {
+ meta.timeout = config.timeout;
+ }
+
+ return meta;
+}
+
function parseProxyHost(row: ProxyHostRow): ProxyHost {
const meta = parseMeta(row.meta ?? null);
return {
@@ -969,7 +1152,8 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
custom_reverse_proxy_json: meta.custom_reverse_proxy_json ?? null,
custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null,
authentik: hydrateAuthentik(meta.authentik),
- load_balancer: hydrateLoadBalancer(meta.load_balancer)
+ load_balancer: hydrateLoadBalancer(meta.load_balancer),
+ dns_resolver: hydrateDnsResolver(meta.dns_resolver)
};
}
@@ -1046,7 +1230,8 @@ export async function updateProxyHost(id: number, input: Partial
custom_reverse_proxy_json: existing.custom_reverse_proxy_json ?? undefined,
custom_pre_handlers_json: existing.custom_pre_handlers_json ?? undefined,
authentik: dehydrateAuthentik(existing.authentik),
- load_balancer: dehydrateLoadBalancer(existing.load_balancer)
+ load_balancer: dehydrateLoadBalancer(existing.load_balancer),
+ dns_resolver: dehydrateDnsResolver(existing.dns_resolver)
};
const meta = buildMeta(existingMeta, input);
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index b1d971a6..9d22a5b7 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -31,6 +31,13 @@ export type LoggingSettings = {
format?: "json" | "console"; // Log format (default: json)
};
+export type DnsSettings = {
+ enabled: boolean;
+ resolvers: string[]; // Primary DNS resolvers (e.g., "1.1.1.1", "8.8.8.8")
+ fallbacks?: string[]; // Fallback DNS resolvers if primary fails
+ timeout?: string; // DNS query timeout (e.g., "5s")
+};
+
export async function getSetting(key: string): Promise> {
const setting = await db.query.settings.findFirst({
where: (table, { eq }) => eq(table.key, key)
@@ -107,3 +114,11 @@ export async function getLoggingSettings(): Promise {
export async function saveLoggingSettings(settings: LoggingSettings): Promise {
await setSetting("logging", settings);
}
+
+export async function getDnsSettings(): Promise {
+ return await getSetting("dns");
+}
+
+export async function saveDnsSettings(settings: DnsSettings): Promise {
+ await setSetting("dns", settings);
+}