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); +}