implemented upstream pinning
This commit is contained in:
20
README.md
20
README.md
@@ -19,6 +19,7 @@ This project provides a web UI for Caddy Server, eliminating the need to manuall
|
||||
- HTTP basic auth access lists
|
||||
- OAuth2/OIDC authentication support
|
||||
- Automatic HTTPS via Caddy's ACME (Let's Encrypt) with Cloudflare DNS-01 support
|
||||
- Optional upstream DNS pinning (resolve upstream hostnames on config apply)
|
||||
- Custom certificate import (internal CA, wildcards, etc.)
|
||||
- Audit logging of all configuration changes
|
||||
- Built with Next.js 16, React 19, Drizzle ORM, and TypeScript
|
||||
@@ -46,7 +47,7 @@ Data persists in Docker volumes (caddy-manager-data, caddy-data, caddy-config, c
|
||||
- **Proxy Hosts** - Reverse proxies with custom headers and upstream pools
|
||||
- **Access Lists** - HTTP basic auth
|
||||
- **Certificates** - Custom SSL/TLS import (automatic Let's Encrypt via Caddy)
|
||||
- **Settings** - ACME email and Cloudflare DNS-01 configuration
|
||||
- **Settings** - ACME email, Cloudflare DNS-01, and upstream DNS pinning defaults
|
||||
- **Audit Log** - Configuration change tracking
|
||||
|
||||
---
|
||||
@@ -114,6 +115,23 @@ Caddy automatically obtains Let's Encrypt certificates for all proxy hosts.
|
||||
|
||||
---
|
||||
|
||||
## Upstream DNS Pinning
|
||||
|
||||
You can enable upstream DNS pinning globally (**Settings → Upstream DNS Pinning**) and override per host (**Proxy Host → Upstream DNS Pinning**).
|
||||
|
||||
When enabled, hostname upstreams are resolved during config save/reload and written to Caddy as concrete IP dials. Address family selection supports:
|
||||
- `both` (preferred, resolves AAAA then A with IPv6 preference)
|
||||
- `ipv6`
|
||||
- `ipv4`
|
||||
|
||||
### Important HTTPS Limitation
|
||||
|
||||
If one reverse proxy handler contains multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those HTTPS upstreams to avoid TLS SNI mismatch. In that case, hostname dials are kept for those HTTPS upstreams.
|
||||
|
||||
HTTP upstreams in the same handler are still eligible for pinning.
|
||||
|
||||
---
|
||||
|
||||
## OAuth Authentication
|
||||
|
||||
Supports any OIDC-compliant provider (Authentik, Keycloak, Auth0, etc.).
|
||||
|
||||
@@ -3,7 +3,16 @@
|
||||
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, type DnsResolverInput } from "@/src/lib/models/proxy-hosts";
|
||||
import {
|
||||
createProxyHost,
|
||||
deleteProxyHost,
|
||||
updateProxyHost,
|
||||
type ProxyHostAuthentikInput,
|
||||
type LoadBalancerInput,
|
||||
type LoadBalancingPolicy,
|
||||
type DnsResolverInput,
|
||||
type UpstreamDnsResolutionInput
|
||||
} from "@/src/lib/models/proxy-hosts";
|
||||
import { getCertificate } from "@/src/lib/models/certificates";
|
||||
import { getCloudflareSettings } from "@/src/lib/settings";
|
||||
|
||||
@@ -159,6 +168,7 @@ function parseOptionalNumber(value: FormDataEntryValue | null): number | null {
|
||||
}
|
||||
|
||||
const VALID_LB_POLICIES: LoadBalancingPolicy[] = ["random", "round_robin", "least_conn", "ip_hash", "first", "header", "cookie", "uri_hash"];
|
||||
const VALID_UPSTREAM_DNS_FAMILIES = ["ipv6", "ipv4", "both"] as const;
|
||||
|
||||
function parseLoadBalancerConfig(formData: FormData): LoadBalancerInput | undefined {
|
||||
if (!formData.has("lb_present")) {
|
||||
@@ -326,6 +336,33 @@ function parseDnsResolverConfig(formData: FormData): DnsResolverInput | undefine
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function parseUpstreamDnsResolutionConfig(formData: FormData): UpstreamDnsResolutionInput | undefined {
|
||||
if (!formData.has("upstream_dns_resolution_present")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const modeRaw = parseOptionalText(formData.get("upstream_dns_resolution_mode")) ?? "inherit";
|
||||
const familyRaw = parseOptionalText(formData.get("upstream_dns_resolution_family")) ?? "inherit";
|
||||
|
||||
const result: UpstreamDnsResolutionInput = {};
|
||||
|
||||
if (modeRaw === "enabled") {
|
||||
result.enabled = true;
|
||||
} else if (modeRaw === "disabled") {
|
||||
result.enabled = false;
|
||||
} else if (modeRaw === "inherit") {
|
||||
result.enabled = null;
|
||||
}
|
||||
|
||||
if (familyRaw === "inherit") {
|
||||
result.family = null;
|
||||
} else if (VALID_UPSTREAM_DNS_FAMILIES.includes(familyRaw as typeof VALID_UPSTREAM_DNS_FAMILIES[number])) {
|
||||
result.family = familyRaw as "ipv6" | "ipv4" | "both";
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
export async function createProxyHostAction(
|
||||
_prevState: ActionState = INITIAL_ACTION_STATE,
|
||||
formData: FormData
|
||||
@@ -362,7 +399,8 @@ export async function createProxyHostAction(
|
||||
custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")),
|
||||
authentik: parseAuthentikConfig(formData),
|
||||
load_balancer: parseLoadBalancerConfig(formData),
|
||||
dns_resolver: parseDnsResolverConfig(formData)
|
||||
dns_resolver: parseDnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData)
|
||||
},
|
||||
userId
|
||||
);
|
||||
@@ -431,7 +469,8 @@ export async function updateProxyHostAction(
|
||||
: undefined,
|
||||
authentik: parseAuthentikConfig(formData),
|
||||
load_balancer: parseLoadBalancerConfig(formData),
|
||||
dns_resolver: parseDnsResolverConfig(formData)
|
||||
dns_resolver: parseDnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData)
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import { useState } from "react";
|
||||
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, DnsSettings } from "@/src/lib/settings";
|
||||
import type {
|
||||
GeneralSettings,
|
||||
AuthentikSettings,
|
||||
MetricsSettings,
|
||||
LoggingSettings,
|
||||
DnsSettings,
|
||||
UpstreamDnsResolutionSettings
|
||||
} from "@/src/lib/settings";
|
||||
import {
|
||||
updateCloudflareSettingsAction,
|
||||
updateGeneralSettingsAction,
|
||||
@@ -11,6 +18,7 @@ import {
|
||||
updateMetricsSettingsAction,
|
||||
updateLoggingSettingsAction,
|
||||
updateDnsSettingsAction,
|
||||
updateUpstreamDnsResolutionSettingsAction,
|
||||
updateInstanceModeAction,
|
||||
updateSlaveMasterTokenAction,
|
||||
createSlaveInstanceAction,
|
||||
@@ -30,6 +38,7 @@ type Props = {
|
||||
metrics: MetricsSettings | null;
|
||||
logging: LoggingSettings | null;
|
||||
dns: DnsSettings | null;
|
||||
upstreamDnsResolution: UpstreamDnsResolutionSettings | null;
|
||||
instanceSync: {
|
||||
mode: "standalone" | "master" | "slave";
|
||||
modeFromEnv: boolean;
|
||||
@@ -41,6 +50,7 @@ type Props = {
|
||||
metrics: boolean;
|
||||
logging: boolean;
|
||||
dns: boolean;
|
||||
upstreamDnsResolution: boolean;
|
||||
};
|
||||
slave: {
|
||||
hasToken: boolean;
|
||||
@@ -64,13 +74,26 @@ type Props = {
|
||||
};
|
||||
};
|
||||
|
||||
export default function SettingsClient({ general, cloudflare, authentik, metrics, logging, dns, instanceSync }: Props) {
|
||||
export default function SettingsClient({
|
||||
general,
|
||||
cloudflare,
|
||||
authentik,
|
||||
metrics,
|
||||
logging,
|
||||
dns,
|
||||
upstreamDnsResolution,
|
||||
instanceSync
|
||||
}: 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);
|
||||
const [upstreamDnsResolutionState, upstreamDnsResolutionFormAction] = useFormState(
|
||||
updateUpstreamDnsResolutionSettingsAction,
|
||||
null
|
||||
);
|
||||
const [instanceModeState, instanceModeFormAction] = useFormState(updateInstanceModeAction, null);
|
||||
const [slaveTokenState, slaveTokenFormAction] = useFormState(updateSlaveMasterTokenAction, null);
|
||||
const [slaveInstanceState, slaveInstanceFormAction] = useFormState(createSlaveInstanceAction, null);
|
||||
@@ -84,6 +107,9 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
|
||||
const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics);
|
||||
const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging);
|
||||
const [dnsOverride, setDnsOverride] = useState(instanceSync.overrides.dns);
|
||||
const [upstreamDnsResolutionOverride, setUpstreamDnsResolutionOverride] = useState(
|
||||
instanceSync.overrides.upstreamDnsResolution
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack spacing={4} sx={{ width: "100%" }}>
|
||||
@@ -488,6 +514,64 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight={600} gutterBottom>
|
||||
Upstream DNS Pinning
|
||||
</Typography>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
|
||||
Optionally resolve upstream hostnames when applying config and pin reverse proxy upstream dials to IP addresses.
|
||||
This can avoid runtime DNS churn and lets you force IPv6, IPv4, or both (IPv6 preferred).
|
||||
</Typography>
|
||||
<Stack component="form" action={upstreamDnsResolutionFormAction} spacing={2}>
|
||||
{upstreamDnsResolutionState?.message && (
|
||||
<Alert severity={upstreamDnsResolutionState.success ? "success" : "error"}>
|
||||
{upstreamDnsResolutionState.message}
|
||||
</Alert>
|
||||
)}
|
||||
{isSlave && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
name="overrideEnabled"
|
||||
checked={upstreamDnsResolutionOverride}
|
||||
onChange={(event) => setUpstreamDnsResolutionOverride(event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Override master settings"
|
||||
/>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={<Checkbox name="enabled" defaultChecked={upstreamDnsResolution?.enabled ?? false} disabled={isSlave && !upstreamDnsResolutionOverride} />}
|
||||
label="Enable upstream DNS pinning during config apply"
|
||||
/>
|
||||
<TextField
|
||||
name="family"
|
||||
label="Address Family Preference"
|
||||
select
|
||||
defaultValue={upstreamDnsResolution?.family ?? "both"}
|
||||
helperText="Both resolves AAAA + A with IPv6 preferred ordering."
|
||||
disabled={isSlave && !upstreamDnsResolutionOverride}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="both">Both (Prefer IPv6)</MenuItem>
|
||||
<MenuItem value="ipv6">IPv6 only</MenuItem>
|
||||
<MenuItem value="ipv4">IPv4 only</MenuItem>
|
||||
</TextField>
|
||||
<Alert severity="info">
|
||||
Host-level settings can override this default. Resolution happens at config save/reload time and resolved IPs are written into
|
||||
Caddy's active config. If one handler has multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those
|
||||
HTTPS upstreams to avoid SNI mismatch.
|
||||
</Alert>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button type="submit" variant="contained">
|
||||
Save upstream DNS pinning settings
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" fontWeight={600} gutterBottom>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { requireAdmin } from "@/src/lib/auth";
|
||||
import { applyCaddyConfig } from "@/src/lib/caddy";
|
||||
import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync";
|
||||
import { createInstance, deleteInstance, updateInstance } from "@/src/lib/models/instances";
|
||||
import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings } from "@/src/lib/settings";
|
||||
import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings, saveUpstreamDnsResolutionSettings } from "@/src/lib/settings";
|
||||
import type { CloudflareSettings } from "@/src/lib/settings";
|
||||
|
||||
type ActionResult = {
|
||||
@@ -14,6 +14,7 @@ type ActionResult = {
|
||||
};
|
||||
|
||||
const MIN_TOKEN_LENGTH = 32;
|
||||
const VALID_UPSTREAM_DNS_FAMILIES = ["ipv6", "ipv4", "both"] as const;
|
||||
|
||||
/**
|
||||
* Validates that a sync token meets minimum security requirements.
|
||||
@@ -322,6 +323,66 @@ export async function updateDnsSettingsAction(_prevState: ActionResult | null, f
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUpstreamDnsResolutionSettingsAction(
|
||||
_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("upstream_dns_resolution");
|
||||
try {
|
||||
await applyCaddyConfig();
|
||||
revalidatePath("/settings");
|
||||
return { success: true, message: "Upstream DNS resolution 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 enabled = formData.get("enabled") === "on";
|
||||
const familyRaw = formData.get("family") ? String(formData.get("family")).trim() : "both";
|
||||
if (!VALID_UPSTREAM_DNS_FAMILIES.includes(familyRaw as typeof VALID_UPSTREAM_DNS_FAMILIES[number])) {
|
||||
return { success: false, message: "Invalid address family selection" };
|
||||
}
|
||||
|
||||
await saveUpstreamDnsResolutionSettings({
|
||||
enabled,
|
||||
family: familyRaw as "ipv6" | "ipv4" | "both"
|
||||
});
|
||||
|
||||
try {
|
||||
await applyCaddyConfig();
|
||||
revalidatePath("/settings");
|
||||
return { success: true, message: "Upstream DNS resolution 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";
|
||||
await syncInstances();
|
||||
return {
|
||||
success: true,
|
||||
message: `Settings saved, but could not apply to Caddy: ${errorMsg}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save upstream DNS resolution settings:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "Failed to save upstream DNS resolution settings"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateInstanceModeAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
|
||||
try {
|
||||
await requireAdmin();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import SettingsClient from "./SettingsClient";
|
||||
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting } from "@/src/lib/settings";
|
||||
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting, getUpstreamDnsResolutionSettings } from "@/src/lib/settings";
|
||||
import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
|
||||
import { listInstances } from "@/src/lib/models/instances";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
@@ -11,17 +11,18 @@ export default async function SettingsPage() {
|
||||
const modeFromEnv = isInstanceModeFromEnv();
|
||||
const tokenFromEnv = isSyncTokenFromEnv();
|
||||
|
||||
const [general, cloudflare, authentik, metrics, logging, dns, instanceMode] = await Promise.all([
|
||||
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode] = await Promise.all([
|
||||
getGeneralSettings(),
|
||||
getCloudflareSettings(),
|
||||
getAuthentikSettings(),
|
||||
getMetricsSettings(),
|
||||
getLoggingSettings(),
|
||||
getDnsSettings(),
|
||||
getUpstreamDnsResolutionSettings(),
|
||||
getInstanceMode()
|
||||
]);
|
||||
|
||||
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns] =
|
||||
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
|
||||
instanceMode === "slave"
|
||||
? await Promise.all([
|
||||
getSetting("general"),
|
||||
@@ -29,9 +30,10 @@ export default async function SettingsPage() {
|
||||
getSetting("authentik"),
|
||||
getSetting("metrics"),
|
||||
getSetting("logging"),
|
||||
getSetting("dns")
|
||||
getSetting("dns"),
|
||||
getSetting("upstream_dns_resolution")
|
||||
])
|
||||
: [null, null, null, null, null, null];
|
||||
: [null, null, null, null, null, null, null];
|
||||
|
||||
const [slaveToken, slaveLastSync] = instanceMode === "slave"
|
||||
? await Promise.all([getSlaveMasterToken(), getSlaveLastSync()])
|
||||
@@ -52,6 +54,7 @@ export default async function SettingsPage() {
|
||||
metrics={metrics}
|
||||
logging={logging}
|
||||
dns={dns}
|
||||
upstreamDnsResolution={upstreamDnsResolution}
|
||||
instanceSync={{
|
||||
mode: instanceMode,
|
||||
modeFromEnv,
|
||||
@@ -62,7 +65,8 @@ export default async function SettingsPage() {
|
||||
authentik: overrideAuthentik !== null,
|
||||
metrics: overrideMetrics !== null,
|
||||
logging: overrideLogging !== null,
|
||||
dns: overrideDns !== null
|
||||
dns: overrideDns !== null,
|
||||
upstreamDnsResolution: overrideUpstreamDnsResolution !== null
|
||||
},
|
||||
slave: instanceMode === "slave" ? {
|
||||
hasToken: Boolean(slaveToken),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AuthentikFields } from "./AuthentikFields";
|
||||
import { DnsResolverFields } from "./DnsResolverFields";
|
||||
import { LoadBalancerFields } from "./LoadBalancerFields";
|
||||
import { SettingsToggles } from "./SettingsToggles";
|
||||
import { UpstreamDnsResolutionFields } from "./UpstreamDnsResolutionFields";
|
||||
import { UpstreamInput } from "./UpstreamInput";
|
||||
|
||||
export function CreateHostDialog({
|
||||
@@ -124,6 +125,7 @@ export function CreateHostDialog({
|
||||
<AuthentikFields defaults={authentikDefaults} authentik={initialData?.authentik} />
|
||||
<LoadBalancerFields loadBalancer={initialData?.load_balancer} />
|
||||
<DnsResolverFields dnsResolver={initialData?.dns_resolver} />
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstream_dns_resolution} />
|
||||
</Stack>
|
||||
</AppDialog>
|
||||
);
|
||||
@@ -220,6 +222,7 @@ export function EditHostDialog({
|
||||
<AuthentikFields authentik={host.authentik} />
|
||||
<LoadBalancerFields loadBalancer={host.load_balancer} />
|
||||
<DnsResolverFields dnsResolver={host.dns_resolver} />
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstream_dns_resolution} />
|
||||
</Stack>
|
||||
</AppDialog>
|
||||
);
|
||||
|
||||
77
src/components/proxy-hosts/UpstreamDnsResolutionFields.tsx
Normal file
77
src/components/proxy-hosts/UpstreamDnsResolutionFields.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Alert, Box, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import type { ProxyHost } from "@/src/lib/models/proxy-hosts";
|
||||
|
||||
type ResolutionMode = "inherit" | "enabled" | "disabled";
|
||||
type FamilyMode = "inherit" | "ipv6" | "ipv4" | "both";
|
||||
|
||||
function toResolutionMode(enabled: boolean | null | undefined): ResolutionMode {
|
||||
if (enabled === true) return "enabled";
|
||||
if (enabled === false) return "disabled";
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
function toFamilyMode(family: "ipv6" | "ipv4" | "both" | null | undefined): FamilyMode {
|
||||
if (family === "ipv6" || family === "ipv4" || family === "both") {
|
||||
return family;
|
||||
}
|
||||
return "inherit";
|
||||
}
|
||||
|
||||
export function UpstreamDnsResolutionFields({
|
||||
upstreamDnsResolution
|
||||
}: {
|
||||
upstreamDnsResolution?: ProxyHost["upstream_dns_resolution"] | null;
|
||||
}) {
|
||||
const mode = toResolutionMode(upstreamDnsResolution?.enabled);
|
||||
const family = toFamilyMode(upstreamDnsResolution?.family);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "info.main",
|
||||
bgcolor: "rgba(2, 136, 209, 0.06)",
|
||||
p: 2.5
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="upstream_dns_resolution_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Upstream DNS Pinning
|
||||
</Typography>
|
||||
<TextField
|
||||
name="upstream_dns_resolution_mode"
|
||||
label="Resolution Mode"
|
||||
select
|
||||
defaultValue={mode}
|
||||
helperText="Inherit uses the global setting. Enabled/Disabled overrides per host."
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="inherit">Inherit Global</MenuItem>
|
||||
<MenuItem value="enabled">Enabled</MenuItem>
|
||||
<MenuItem value="disabled">Disabled</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
name="upstream_dns_resolution_family"
|
||||
label="Address Family Preference"
|
||||
select
|
||||
defaultValue={family}
|
||||
helperText="Both resolves AAAA + A with IPv6 preferred ordering."
|
||||
size="small"
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="inherit">Inherit Global</MenuItem>
|
||||
<MenuItem value="both">Both (Prefer IPv6)</MenuItem>
|
||||
<MenuItem value="ipv6">IPv6 only</MenuItem>
|
||||
<MenuItem value="ipv4">IPv4 only</MenuItem>
|
||||
</TextField>
|
||||
<Alert severity="info">
|
||||
When enabled, hostname upstreams are resolved during config apply and written to Caddy as concrete IP dials. If this handler has
|
||||
multiple different HTTPS upstream hostnames, HTTPS pinning is skipped for those HTTPS upstreams to avoid SNI mismatch.
|
||||
</Alert>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
508
src/lib/caddy.ts
508
src/lib/caddy.ts
@@ -1,9 +1,22 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { Resolver } from "node:dns/promises";
|
||||
import { join } from "node:path";
|
||||
import { isIP } from "node:net";
|
||||
import crypto from "node:crypto";
|
||||
import db, { nowIso } from "./db";
|
||||
import { config } from "./config";
|
||||
import { getCloudflareSettings, getGeneralSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, setSetting } from "./settings";
|
||||
import {
|
||||
getCloudflareSettings,
|
||||
getGeneralSettings,
|
||||
getMetricsSettings,
|
||||
getLoggingSettings,
|
||||
getDnsSettings,
|
||||
getUpstreamDnsResolutionSettings,
|
||||
setSetting,
|
||||
type DnsSettings,
|
||||
type UpstreamDnsAddressFamily,
|
||||
type UpstreamDnsResolutionSettings
|
||||
} from "./settings";
|
||||
import { syncInstances } from "./instance-sync";
|
||||
import {
|
||||
accessListEntries,
|
||||
@@ -55,12 +68,18 @@ type DnsResolverMeta = {
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
type UpstreamDnsResolutionMeta = {
|
||||
enabled?: boolean;
|
||||
family?: UpstreamDnsAddressFamily;
|
||||
};
|
||||
|
||||
type ProxyHostMeta = {
|
||||
custom_reverse_proxy_json?: string;
|
||||
custom_pre_handlers_json?: string;
|
||||
authentik?: ProxyHostAuthentikMeta;
|
||||
load_balancer?: LoadBalancerMeta;
|
||||
dns_resolver?: DnsResolverMeta;
|
||||
upstream_dns_resolution?: UpstreamDnsResolutionMeta;
|
||||
};
|
||||
|
||||
type ProxyHostAuthentikMeta = {
|
||||
@@ -231,6 +250,406 @@ function parseCustomHandlers(value: string | null | undefined): Record<string, u
|
||||
return handlers;
|
||||
}
|
||||
|
||||
const VALID_UPSTREAM_DNS_FAMILIES: UpstreamDnsAddressFamily[] = ["ipv6", "ipv4", "both"];
|
||||
|
||||
type ParsedUpstreamTarget = {
|
||||
original: string;
|
||||
dial: string;
|
||||
scheme: "http" | "https" | null;
|
||||
host: string | null;
|
||||
port: string | null;
|
||||
};
|
||||
|
||||
type UpstreamDnsResolutionRouteConfig = {
|
||||
enabled: boolean | null;
|
||||
family: UpstreamDnsAddressFamily | null;
|
||||
};
|
||||
|
||||
type EffectiveUpstreamDnsResolution = {
|
||||
enabled: boolean;
|
||||
family: UpstreamDnsAddressFamily;
|
||||
};
|
||||
|
||||
function formatDialAddress(host: string, port: string) {
|
||||
return isIP(host) === 6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
}
|
||||
|
||||
function parseHostPort(value: string): { host: string; port: string } | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("[")) {
|
||||
const closeIndex = trimmed.indexOf("]");
|
||||
if (closeIndex <= 1) {
|
||||
return null;
|
||||
}
|
||||
const host = trimmed.slice(1, closeIndex);
|
||||
const remainder = trimmed.slice(closeIndex + 1);
|
||||
if (!remainder.startsWith(":")) {
|
||||
return null;
|
||||
}
|
||||
const port = remainder.slice(1).trim();
|
||||
if (!port) {
|
||||
return null;
|
||||
}
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
const firstColon = trimmed.indexOf(":");
|
||||
const lastColon = trimmed.lastIndexOf(":");
|
||||
if (firstColon === -1 || firstColon !== lastColon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = trimmed.slice(0, lastColon).trim();
|
||||
const port = trimmed.slice(lastColon + 1).trim();
|
||||
if (!host || !port) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
function parseUpstreamTarget(upstream: string): ParsedUpstreamTarget {
|
||||
const trimmed = upstream.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
original: upstream,
|
||||
dial: upstream,
|
||||
scheme: null,
|
||||
host: null,
|
||||
port: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
const scheme = url.protocol === "https:" ? "https" : "http";
|
||||
const port = url.port || (scheme === "https" ? "443" : "80");
|
||||
const host = url.hostname;
|
||||
return {
|
||||
original: trimmed,
|
||||
dial: formatDialAddress(host, port),
|
||||
scheme,
|
||||
host,
|
||||
port
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore and parse as host:port below.
|
||||
}
|
||||
|
||||
const parsed = parseHostPort(trimmed);
|
||||
if (!parsed) {
|
||||
return {
|
||||
original: trimmed,
|
||||
dial: trimmed,
|
||||
scheme: null,
|
||||
host: null,
|
||||
port: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
original: trimmed,
|
||||
dial: formatDialAddress(parsed.host, parsed.port),
|
||||
scheme: null,
|
||||
host: parsed.host,
|
||||
port: parsed.port
|
||||
};
|
||||
}
|
||||
|
||||
function toDurationMs(value: string | null | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const regex = /(\d+(?:\.\d+)?)(ms|s|m|h)/g;
|
||||
let total = 0;
|
||||
let matched = false;
|
||||
let consumed = 0;
|
||||
|
||||
while (true) {
|
||||
const match = regex.exec(trimmed);
|
||||
if (!match) {
|
||||
break;
|
||||
}
|
||||
|
||||
matched = true;
|
||||
consumed += match[0].length;
|
||||
const valueNum = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(valueNum)) {
|
||||
return null;
|
||||
}
|
||||
const unit = match[2];
|
||||
if (unit === "ms") {
|
||||
total += valueNum;
|
||||
} else if (unit === "s") {
|
||||
total += valueNum * 1000;
|
||||
} else if (unit === "m") {
|
||||
total += valueNum * 60_000;
|
||||
} else if (unit === "h") {
|
||||
total += valueNum * 3_600_000;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched || consumed !== trimmed.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rounded = Math.round(total);
|
||||
return rounded > 0 ? rounded : null;
|
||||
}
|
||||
|
||||
function parseUpstreamDnsResolutionConfig(
|
||||
meta: UpstreamDnsResolutionMeta | undefined | null
|
||||
): UpstreamDnsResolutionRouteConfig | null {
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabled = typeof meta.enabled === "boolean" ? meta.enabled : null;
|
||||
const family = meta.family && VALID_UPSTREAM_DNS_FAMILIES.includes(meta.family) ? meta.family : null;
|
||||
|
||||
if (enabled === null && family === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
family
|
||||
};
|
||||
}
|
||||
|
||||
function resolveEffectiveUpstreamDnsResolution(
|
||||
globalSetting: UpstreamDnsResolutionSettings | null,
|
||||
hostSetting: UpstreamDnsResolutionRouteConfig | null
|
||||
): EffectiveUpstreamDnsResolution {
|
||||
const globalFamily = globalSetting?.family && VALID_UPSTREAM_DNS_FAMILIES.includes(globalSetting.family)
|
||||
? globalSetting.family
|
||||
: "both";
|
||||
const globalEnabled = Boolean(globalSetting?.enabled);
|
||||
|
||||
return {
|
||||
enabled: hostSetting?.enabled ?? globalEnabled,
|
||||
family: hostSetting?.family ?? globalFamily
|
||||
};
|
||||
}
|
||||
|
||||
function getLookupServers(dnsConfig: DnsResolverRouteConfig | null, globalDnsSettings: DnsSettings | null): string[] {
|
||||
if (dnsConfig && dnsConfig.enabled && dnsConfig.resolvers.length > 0) {
|
||||
const servers = [...dnsConfig.resolvers];
|
||||
if (dnsConfig.fallbacks && dnsConfig.fallbacks.length > 0) {
|
||||
servers.push(...dnsConfig.fallbacks);
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
if (globalDnsSettings?.enabled && Array.isArray(globalDnsSettings.resolvers) && globalDnsSettings.resolvers.length > 0) {
|
||||
const servers = [...globalDnsSettings.resolvers];
|
||||
if (Array.isArray(globalDnsSettings.fallbacks) && globalDnsSettings.fallbacks.length > 0) {
|
||||
servers.push(...globalDnsSettings.fallbacks);
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getLookupTimeoutMs(dnsConfig: DnsResolverRouteConfig | null, globalDnsSettings: DnsSettings | null): number | null {
|
||||
const hostTimeout = toDurationMs(dnsConfig?.timeout ?? null);
|
||||
if (hostTimeout !== null) {
|
||||
return hostTimeout;
|
||||
}
|
||||
|
||||
if (globalDnsSettings?.enabled) {
|
||||
const globalTimeout = toDurationMs(globalDnsSettings.timeout ?? null);
|
||||
if (globalTimeout !== null) {
|
||||
return globalTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number | null, timeoutLabel: string): Promise<T> {
|
||||
if (!timeoutMs || timeoutMs <= 0) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`${timeoutLabel} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveHostnameAddresses(
|
||||
resolver: Resolver,
|
||||
hostname: string,
|
||||
family: UpstreamDnsAddressFamily,
|
||||
timeoutMs: number | null
|
||||
): Promise<string[]> {
|
||||
const errors: string[] = [];
|
||||
const resolved: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const resolve6 = async () => {
|
||||
try {
|
||||
return await withTimeout(resolver.resolve6(hostname), timeoutMs, `AAAA lookup for ${hostname}`);
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const resolve4 = async () => {
|
||||
try {
|
||||
return await withTimeout(resolver.resolve4(hostname), timeoutMs, `A lookup for ${hostname}`);
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error.message : String(error));
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const pushUnique = (addresses: string[]) => {
|
||||
for (const address of addresses) {
|
||||
if (!seen.has(address)) {
|
||||
seen.add(address);
|
||||
resolved.push(address);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (family === "ipv6") {
|
||||
pushUnique(await resolve6());
|
||||
} else if (family === "ipv4") {
|
||||
pushUnique(await resolve4());
|
||||
} else {
|
||||
pushUnique(await resolve6());
|
||||
pushUnique(await resolve4());
|
||||
}
|
||||
|
||||
if (resolved.length === 0 && errors.length > 0) {
|
||||
throw new Error(errors.join("; "));
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
type ResolveUpstreamsResult = {
|
||||
upstreams: Array<{ dial: string }>;
|
||||
hasHttpsUpstream: boolean;
|
||||
httpsTlsServerName: string | null;
|
||||
};
|
||||
|
||||
async function resolveUpstreamDials(
|
||||
row: ProxyHostRow,
|
||||
upstreams: string[],
|
||||
dnsConfig: DnsResolverRouteConfig | null,
|
||||
globalDnsSettings: DnsSettings | null,
|
||||
dnsResolution: EffectiveUpstreamDnsResolution
|
||||
): Promise<ResolveUpstreamsResult> {
|
||||
const parsedTargets = upstreams.map(parseUpstreamTarget);
|
||||
const hasHttpsUpstream = parsedTargets.some((target) => target.scheme === "https");
|
||||
|
||||
if (!dnsResolution.enabled) {
|
||||
return {
|
||||
upstreams: parsedTargets.map((target) => ({ dial: target.dial })),
|
||||
hasHttpsUpstream,
|
||||
httpsTlsServerName: null
|
||||
};
|
||||
}
|
||||
|
||||
const httpsHostnames = Array.from(
|
||||
new Set(
|
||||
parsedTargets
|
||||
.filter((target) => target.scheme === "https" && target.host && target.port && isIP(target.host) === 0)
|
||||
.map((target) => target.host as string)
|
||||
)
|
||||
);
|
||||
const canResolveHttps = httpsHostnames.length <= 1;
|
||||
if (!canResolveHttps) {
|
||||
console.warn(
|
||||
`[caddy] Skipping DNS pinning for HTTPS upstreams on host "${row.name}" because multiple TLS server names are configured.`
|
||||
);
|
||||
}
|
||||
|
||||
const resolver = new Resolver();
|
||||
const lookupServers = getLookupServers(dnsConfig, globalDnsSettings);
|
||||
if (lookupServers.length > 0) {
|
||||
try {
|
||||
resolver.setServers(lookupServers);
|
||||
} catch (error) {
|
||||
console.warn(`[caddy] Failed to set custom DNS servers for upstream pinning`, error);
|
||||
}
|
||||
}
|
||||
const timeoutMs = getLookupTimeoutMs(dnsConfig, globalDnsSettings);
|
||||
|
||||
const dials: string[] = [];
|
||||
for (const target of parsedTargets) {
|
||||
if (!target.host || !target.port || isIP(target.host) !== 0) {
|
||||
dials.push(target.dial);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.scheme === "https" && !canResolveHttps) {
|
||||
dials.push(target.dial);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const addresses = await resolveHostnameAddresses(resolver, target.host, dnsResolution.family, timeoutMs);
|
||||
if (addresses.length === 0) {
|
||||
dials.push(target.dial);
|
||||
continue;
|
||||
}
|
||||
for (const address of addresses) {
|
||||
dials.push(formatDialAddress(address, target.port));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[caddy] Failed to resolve upstream "${target.original}" for host "${row.name}", falling back to hostname dial.`,
|
||||
error
|
||||
);
|
||||
dials.push(target.dial);
|
||||
}
|
||||
}
|
||||
|
||||
const dedupedDials: Array<{ dial: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const dial of dials) {
|
||||
if (!seen.has(dial)) {
|
||||
seen.add(dial);
|
||||
dedupedDials.push({ dial });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
upstreams: dedupedDials,
|
||||
hasHttpsUpstream,
|
||||
httpsTlsServerName: canResolveHttps && httpsHostnames.length === 1 ? httpsHostnames[0] : null
|
||||
};
|
||||
}
|
||||
|
||||
function writeCertificateFiles(cert: CertificateRow) {
|
||||
if (cert.type !== "imported" || !cert.certificate_pem || !cert.private_key_pem) {
|
||||
return null;
|
||||
@@ -286,12 +705,17 @@ function collectCertificateUsage(rows: ProxyHostRow[], certificates: Map<number,
|
||||
return { usage, autoManagedDomains };
|
||||
}
|
||||
|
||||
function buildProxyRoutes(
|
||||
type BuildProxyRoutesOptions = {
|
||||
globalDnsSettings: DnsSettings | null;
|
||||
globalUpstreamDnsResolutionSettings: UpstreamDnsResolutionSettings | null;
|
||||
};
|
||||
|
||||
async function buildProxyRoutes(
|
||||
rows: ProxyHostRow[],
|
||||
accessAccounts: Map<number, AccessListEntryRow[]>,
|
||||
tlsReadyCertificates: Set<number>,
|
||||
autoManagedDomains: Set<string>
|
||||
): CaddyHttpRoute[] {
|
||||
options: BuildProxyRoutesOptions
|
||||
): Promise<CaddyHttpRoute[]> {
|
||||
const routes: CaddyHttpRoute[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -373,25 +797,24 @@ function buildProxyRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Parse upstream URLs to extract host:port for Caddy's dial field
|
||||
const parsedUpstreams = upstreams.map((upstream) => {
|
||||
try {
|
||||
const url = new URL(upstream);
|
||||
// Use default ports if not specified: 443 for https, 80 for http
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
const dial = `${url.hostname}:${port}`;
|
||||
return { dial };
|
||||
} catch {
|
||||
// If URL parsing fails, use the upstream as-is
|
||||
return { dial: upstream };
|
||||
}
|
||||
});
|
||||
|
||||
const hasHttpsUpstream = upstreams.some((upstream) => upstream.startsWith("https://"));
|
||||
const lbConfig = parseLoadBalancerConfig(meta.load_balancer);
|
||||
const dnsConfig = parseDnsResolverConfig(meta.dns_resolver);
|
||||
const hostDnsResolutionConfig = parseUpstreamDnsResolutionConfig(meta.upstream_dns_resolution);
|
||||
const effectiveDnsResolution = resolveEffectiveUpstreamDnsResolution(
|
||||
options.globalUpstreamDnsResolutionSettings,
|
||||
hostDnsResolutionConfig
|
||||
);
|
||||
const resolvedUpstreams = await resolveUpstreamDials(
|
||||
row,
|
||||
upstreams,
|
||||
dnsConfig,
|
||||
options.globalDnsSettings,
|
||||
effectiveDnsResolution
|
||||
);
|
||||
|
||||
const reverseProxyHandler: Record<string, unknown> = {
|
||||
handler: "reverse_proxy",
|
||||
upstreams: parsedUpstreams
|
||||
upstreams: resolvedUpstreams.upstreams
|
||||
};
|
||||
|
||||
// Authentik outpost handler will be added later after protected paths
|
||||
@@ -450,21 +873,23 @@ function buildProxyRoutes(
|
||||
}
|
||||
|
||||
// Configure TLS transport for HTTPS upstreams
|
||||
if (hasHttpsUpstream) {
|
||||
if (resolvedUpstreams.hasHttpsUpstream) {
|
||||
const tlsTransport: Record<string, unknown> = row.skip_https_hostname_validation
|
||||
? {
|
||||
insecure_skip_verify: true
|
||||
}
|
||||
: {};
|
||||
if (resolvedUpstreams.httpsTlsServerName) {
|
||||
tlsTransport.server_name = resolvedUpstreams.httpsTlsServerName;
|
||||
}
|
||||
|
||||
reverseProxyHandler.transport = {
|
||||
protocol: "http",
|
||||
tls: row.skip_https_hostname_validation
|
||||
? {
|
||||
insecure_skip_verify: true
|
||||
}
|
||||
: {}
|
||||
tls: tlsTransport
|
||||
};
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -738,7 +1163,7 @@ function buildTlsConnectionPolicies(
|
||||
async function buildTlsAutomation(
|
||||
usage: Map<number, CertificateUsage>,
|
||||
autoManagedDomains: Set<string>,
|
||||
options: { acmeEmail?: string }
|
||||
options: { acmeEmail?: string; dnsSettings?: DnsSettings | null }
|
||||
) {
|
||||
const managedEntries = Array.from(usage.values()).filter(
|
||||
(entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.auto_renew)
|
||||
@@ -755,7 +1180,7 @@ async function buildTlsAutomation(
|
||||
const cloudflare = await getCloudflareSettings();
|
||||
const hasCloudflare = cloudflare && cloudflare.apiToken;
|
||||
|
||||
const dnsSettings = await getDnsSettings();
|
||||
const dnsSettings = options.dnsSettings ?? await getDnsSettings();
|
||||
const hasDnsResolvers = dnsSettings && dnsSettings.enabled && dnsSettings.resolvers && dnsSettings.resolvers.length > 0;
|
||||
|
||||
// Build DNS resolvers list (primary + fallbacks)
|
||||
@@ -956,9 +1381,14 @@ async function buildCaddyDocument() {
|
||||
}, new Map());
|
||||
|
||||
const { usage: certificateUsage, autoManagedDomains } = collectCertificateUsage(proxyHostRows, certificateMap);
|
||||
const generalSettings = await getGeneralSettings();
|
||||
const [generalSettings, dnsSettings, upstreamDnsResolutionSettings] = await Promise.all([
|
||||
getGeneralSettings(),
|
||||
getDnsSettings(),
|
||||
getUpstreamDnsResolutionSettings()
|
||||
]);
|
||||
const { tlsApp, managedCertificateIds } = await buildTlsAutomation(certificateUsage, autoManagedDomains, {
|
||||
acmeEmail: generalSettings?.acmeEmail
|
||||
acmeEmail: generalSettings?.acmeEmail,
|
||||
dnsSettings
|
||||
});
|
||||
const { policies: tlsConnectionPolicies, readyCertificates } = buildTlsConnectionPolicies(
|
||||
certificateUsage,
|
||||
@@ -966,9 +1396,15 @@ async function buildCaddyDocument() {
|
||||
autoManagedDomains
|
||||
);
|
||||
|
||||
const httpRoutes: CaddyHttpRoute[] = [
|
||||
...buildProxyRoutes(proxyHostRows, accessMap, readyCertificates, autoManagedDomains)
|
||||
];
|
||||
const httpRoutes: CaddyHttpRoute[] = await buildProxyRoutes(
|
||||
proxyHostRows,
|
||||
accessMap,
|
||||
readyCertificates,
|
||||
{
|
||||
globalDnsSettings: dnsSettings,
|
||||
globalUpstreamDnsResolutionSettings: upstreamDnsResolutionSettings
|
||||
}
|
||||
);
|
||||
|
||||
const hasTls = tlsConnectionPolicies.length > 0;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export type SyncSettings = {
|
||||
metrics: unknown | null;
|
||||
logging: unknown | null;
|
||||
dns: unknown | null;
|
||||
upstream_dns_resolution: unknown | null;
|
||||
};
|
||||
|
||||
export type SyncPayload = {
|
||||
@@ -241,7 +242,8 @@ export async function buildSyncPayload(): Promise<SyncPayload> {
|
||||
authentik: await getSetting("authentik"),
|
||||
metrics: await getSetting("metrics"),
|
||||
logging: await getSetting("logging"),
|
||||
dns: await getSetting("dns")
|
||||
dns: await getSetting("dns"),
|
||||
upstream_dns_resolution: await getSetting("upstream_dns_resolution")
|
||||
};
|
||||
|
||||
const sanitizedAccessLists = accessListRows.map((row) => ({
|
||||
@@ -395,6 +397,7 @@ export async function applySyncPayload(payload: SyncPayload) {
|
||||
await setSyncedSetting("metrics", payload.settings.metrics);
|
||||
await setSyncedSetting("logging", payload.settings.logging);
|
||||
await setSyncedSetting("dns", payload.settings.dns);
|
||||
await setSyncedSetting("upstream_dns_resolution", payload.settings.upstream_dns_resolution ?? null);
|
||||
|
||||
// better-sqlite3 is synchronous, so transaction callback must be synchronous
|
||||
db.transaction((tx) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ const DEFAULT_AUTHENTIK_HEADERS = [
|
||||
];
|
||||
|
||||
const DEFAULT_AUTHENTIK_TRUSTED_PROXIES = ["private_ranges"];
|
||||
const VALID_UPSTREAM_DNS_FAMILIES: UpstreamDnsAddressFamily[] = ["ipv6", "ipv4", "both"];
|
||||
|
||||
// Load Balancer Types
|
||||
export type LoadBalancingPolicy = "random" | "round_robin" | "least_conn" | "ip_hash" | "first" | "header" | "cookie" | "uri_hash";
|
||||
@@ -135,6 +136,23 @@ type DnsResolverMeta = {
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
export type UpstreamDnsAddressFamily = "ipv6" | "ipv4" | "both";
|
||||
|
||||
export type UpstreamDnsResolutionConfig = {
|
||||
enabled: boolean | null;
|
||||
family: UpstreamDnsAddressFamily | null;
|
||||
};
|
||||
|
||||
export type UpstreamDnsResolutionInput = {
|
||||
enabled?: boolean | null;
|
||||
family?: UpstreamDnsAddressFamily | null;
|
||||
};
|
||||
|
||||
type UpstreamDnsResolutionMeta = {
|
||||
enabled?: boolean;
|
||||
family?: UpstreamDnsAddressFamily;
|
||||
};
|
||||
|
||||
export type ProxyHostAuthentikConfig = {
|
||||
enabled: boolean;
|
||||
outpostDomain: string | null;
|
||||
@@ -174,6 +192,7 @@ type ProxyHostMeta = {
|
||||
authentik?: ProxyHostAuthentikMeta;
|
||||
load_balancer?: LoadBalancerMeta;
|
||||
dns_resolver?: DnsResolverMeta;
|
||||
upstream_dns_resolution?: UpstreamDnsResolutionMeta;
|
||||
};
|
||||
|
||||
export type ProxyHost = {
|
||||
@@ -197,6 +216,7 @@ export type ProxyHost = {
|
||||
authentik: ProxyHostAuthentikConfig | null;
|
||||
load_balancer: LoadBalancerConfig | null;
|
||||
dns_resolver: DnsResolverConfig | null;
|
||||
upstream_dns_resolution: UpstreamDnsResolutionConfig | null;
|
||||
};
|
||||
|
||||
export type ProxyHostInput = {
|
||||
@@ -217,6 +237,7 @@ export type ProxyHostInput = {
|
||||
authentik?: ProxyHostAuthentikInput | null;
|
||||
load_balancer?: LoadBalancerInput | null;
|
||||
dns_resolver?: DnsResolverInput | null;
|
||||
upstream_dns_resolution?: UpstreamDnsResolutionInput | null;
|
||||
};
|
||||
|
||||
type ProxyHostRow = typeof proxyHosts.$inferSelect;
|
||||
@@ -428,6 +449,25 @@ function sanitizeDnsResolverMeta(meta: DnsResolverMeta | undefined): DnsResolver
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function sanitizeUpstreamDnsResolutionMeta(
|
||||
meta: UpstreamDnsResolutionMeta | undefined
|
||||
): UpstreamDnsResolutionMeta | undefined {
|
||||
if (!meta) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: UpstreamDnsResolutionMeta = {};
|
||||
if (meta.enabled !== undefined) {
|
||||
normalized.enabled = Boolean(meta.enabled);
|
||||
}
|
||||
|
||||
if (meta.family && VALID_UPSTREAM_DNS_FAMILIES.includes(meta.family)) {
|
||||
normalized.family = meta.family;
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function serializeMeta(meta: ProxyHostMeta | null | undefined) {
|
||||
if (!meta) {
|
||||
return null;
|
||||
@@ -458,6 +498,11 @@ function serializeMeta(meta: ProxyHostMeta | null | undefined) {
|
||||
normalized.dns_resolver = dnsResolver;
|
||||
}
|
||||
|
||||
const upstreamDnsResolution = sanitizeUpstreamDnsResolutionMeta(meta.upstream_dns_resolution);
|
||||
if (upstreamDnsResolution) {
|
||||
normalized.upstream_dns_resolution = upstreamDnsResolution;
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null;
|
||||
}
|
||||
|
||||
@@ -472,7 +517,8 @@ function parseMeta(value: string | null): ProxyHostMeta {
|
||||
custom_pre_handlers_json: normalizeMetaValue(parsed.custom_pre_handlers_json ?? null) ?? undefined,
|
||||
authentik: sanitizeAuthentikMeta(parsed.authentik),
|
||||
load_balancer: sanitizeLoadBalancerMeta(parsed.load_balancer),
|
||||
dns_resolver: sanitizeDnsResolverMeta(parsed.dns_resolver)
|
||||
dns_resolver: sanitizeDnsResolverMeta(parsed.dns_resolver),
|
||||
upstream_dns_resolution: sanitizeUpstreamDnsResolutionMeta(parsed.upstream_dns_resolution)
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse proxy host meta", error);
|
||||
@@ -825,6 +871,38 @@ function normalizeDnsResolverInput(
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function normalizeUpstreamDnsResolutionInput(
|
||||
input: UpstreamDnsResolutionInput | null | undefined,
|
||||
existing: UpstreamDnsResolutionMeta | undefined
|
||||
): UpstreamDnsResolutionMeta | undefined {
|
||||
if (input === undefined) {
|
||||
return existing;
|
||||
}
|
||||
if (input === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const next: UpstreamDnsResolutionMeta = { ...(existing ?? {}) };
|
||||
|
||||
if (input.enabled !== undefined) {
|
||||
if (input.enabled === null) {
|
||||
delete next.enabled;
|
||||
} else {
|
||||
next.enabled = Boolean(input.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.family !== undefined) {
|
||||
if (input.family && VALID_UPSTREAM_DNS_FAMILIES.includes(input.family)) {
|
||||
next.family = input.family;
|
||||
} else {
|
||||
delete next.family;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): string | null {
|
||||
const next: ProxyHostMeta = { ...existing };
|
||||
|
||||
@@ -873,6 +951,18 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.upstream_dns_resolution !== undefined) {
|
||||
const upstreamDnsResolution = normalizeUpstreamDnsResolutionInput(
|
||||
input.upstream_dns_resolution,
|
||||
existing.upstream_dns_resolution
|
||||
);
|
||||
if (upstreamDnsResolution) {
|
||||
next.upstream_dns_resolution = upstreamDnsResolution;
|
||||
} else {
|
||||
delete next.upstream_dns_resolution;
|
||||
}
|
||||
}
|
||||
|
||||
return serializeMeta(next);
|
||||
}
|
||||
|
||||
@@ -1131,6 +1221,38 @@ function dehydrateDnsResolver(config: DnsResolverConfig | null): DnsResolverMeta
|
||||
return meta;
|
||||
}
|
||||
|
||||
function hydrateUpstreamDnsResolution(meta: UpstreamDnsResolutionMeta | undefined): UpstreamDnsResolutionConfig | null {
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enabled = meta.enabled === undefined ? null : Boolean(meta.enabled);
|
||||
const family = meta.family && VALID_UPSTREAM_DNS_FAMILIES.includes(meta.family) ? meta.family : null;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
family
|
||||
};
|
||||
}
|
||||
|
||||
function dehydrateUpstreamDnsResolution(
|
||||
config: UpstreamDnsResolutionConfig | null
|
||||
): UpstreamDnsResolutionMeta | undefined {
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const meta: UpstreamDnsResolutionMeta = {};
|
||||
if (config.enabled !== null) {
|
||||
meta.enabled = Boolean(config.enabled);
|
||||
}
|
||||
if (config.family && VALID_UPSTREAM_DNS_FAMILIES.includes(config.family)) {
|
||||
meta.family = config.family;
|
||||
}
|
||||
|
||||
return Object.keys(meta).length > 0 ? meta : undefined;
|
||||
}
|
||||
|
||||
function parseProxyHost(row: ProxyHostRow): ProxyHost {
|
||||
const meta = parseMeta(row.meta ?? null);
|
||||
return {
|
||||
@@ -1153,7 +1275,8 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
|
||||
custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null,
|
||||
authentik: hydrateAuthentik(meta.authentik),
|
||||
load_balancer: hydrateLoadBalancer(meta.load_balancer),
|
||||
dns_resolver: hydrateDnsResolver(meta.dns_resolver)
|
||||
dns_resolver: hydrateDnsResolver(meta.dns_resolver),
|
||||
upstream_dns_resolution: hydrateUpstreamDnsResolution(meta.upstream_dns_resolution)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1232,7 +1355,8 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
|
||||
custom_pre_handlers_json: existing.custom_pre_handlers_json ?? undefined,
|
||||
authentik: dehydrateAuthentik(existing.authentik),
|
||||
load_balancer: dehydrateLoadBalancer(existing.load_balancer),
|
||||
dns_resolver: dehydrateDnsResolver(existing.dns_resolver)
|
||||
dns_resolver: dehydrateDnsResolver(existing.dns_resolver),
|
||||
upstream_dns_resolution: dehydrateUpstreamDnsResolution(existing.upstream_dns_resolution)
|
||||
};
|
||||
const meta = buildMeta(existingMeta, input);
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ export type DnsSettings = {
|
||||
timeout?: string; // DNS query timeout (e.g., "5s")
|
||||
};
|
||||
|
||||
export type UpstreamDnsAddressFamily = "ipv6" | "ipv4" | "both";
|
||||
|
||||
export type UpstreamDnsResolutionSettings = {
|
||||
enabled: boolean;
|
||||
family: UpstreamDnsAddressFamily;
|
||||
};
|
||||
|
||||
type InstanceMode = "standalone" | "master" | "slave";
|
||||
|
||||
const INSTANCE_MODE_KEY = "instance_mode";
|
||||
@@ -157,3 +164,11 @@ export async function getDnsSettings(): Promise<DnsSettings | null> {
|
||||
export async function saveDnsSettings(settings: DnsSettings): Promise<void> {
|
||||
await setSetting("dns", settings);
|
||||
}
|
||||
|
||||
export async function getUpstreamDnsResolutionSettings(): Promise<UpstreamDnsResolutionSettings | null> {
|
||||
return await getEffectiveSetting<UpstreamDnsResolutionSettings>("upstream_dns_resolution");
|
||||
}
|
||||
|
||||
export async function saveUpstreamDnsResolutionSettings(settings: UpstreamDnsResolutionSettings): Promise<void> {
|
||||
await setSetting("upstream_dns_resolution", settings);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user