import { mkdirSync } 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 { expandPrivateRanges, isPlainObject, mergeDeep, parseJson, parseOptionalJson, parseCustomHandlers, formatDialAddress, parseUpstreamTarget, toDurationMs, } from "./caddy-utils"; import { groupHostPatternsByPriority, sortAutomationPoliciesBySubjectPriority, sortRoutesByHostPriority, sortTlsPoliciesBySniPriority, } from "./host-pattern-priority"; import http from "node:http"; import https from "node:https"; import db, { nowIso } from "./db"; import { eq, isNull } from "drizzle-orm"; import { config } from "./config"; import { getCloudflareSettings, getGeneralSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getUpstreamDnsResolutionSettings, getGeoBlockSettings, getWafSettings, setSetting, type DnsSettings, type UpstreamDnsAddressFamily, type UpstreamDnsResolutionSettings, type GeoBlockSettings, type WafSettings } from "./settings"; import { syncInstances } from "./instance-sync"; import { accessListEntries, certificates, caCertificates, issuedClientCertificates, proxyHosts, l4ProxyHosts } from "./db/schema"; import { type GeoBlockMode, type WafHostConfig, type MtlsConfig, type RedirectRule, type RewriteConfig, type LocationRule } from "./models/proxy-hosts"; import { buildClientAuthentication, groupMtlsDomainsByCaSet, buildMtlsRbacSubroutes, type MtlsAccessRuleLike } from "./caddy-mtls"; import { buildRoleFingerprintMap, buildCertFingerprintMap, buildRoleCertIdMap } from "./models/mtls-roles"; import { getAccessRulesForHosts } from "./models/mtls-access-rules"; import { buildWafHandler, resolveEffectiveWaf } from "./caddy-waf"; const CERTS_DIR = process.env.CERTS_DIRECTORY || join(process.cwd(), "data", "certs"); mkdirSync(CERTS_DIR, { recursive: true, mode: 0o700 }); const DEFAULT_AUTHENTIK_HEADERS = [ "X-Authentik-Username", "X-Authentik-Groups", "X-Authentik-Entitlements", "X-Authentik-Email", "X-Authentik-Name", "X-Authentik-Uid", "X-Authentik-Jwt", "X-Authentik-Meta-Jwks", "X-Authentik-Meta-Outpost", "X-Authentik-Meta-Provider", "X-Authentik-Meta-App", "X-Authentik-Meta-Version" ]; const DEFAULT_AUTHENTIK_TRUSTED_PROXIES = ["private_ranges"]; type ProxyHostRow = { id: number; name: string; domains: string; upstreams: string; certificateId: number | null; accessListId: number | null; sslForced: number; hstsEnabled: number; hstsSubdomains: number; allowWebsocket: number; preserveHostHeader: number; skipHttpsHostnameValidation: number; meta: string | null; enabled: number; }; type DnsResolverMeta = { enabled?: boolean; resolvers?: string[]; fallbacks?: string[]; timeout?: string; }; type UpstreamDnsResolutionMeta = { enabled?: boolean; family?: UpstreamDnsAddressFamily; }; type CpmForwardAuthMeta = { enabled?: boolean; protected_paths?: string[]; }; type ProxyHostMeta = { custom_reverse_proxy_json?: string; custom_pre_handlers_json?: string; authentik?: ProxyHostAuthentikMeta; cpm_forward_auth?: CpmForwardAuthMeta; load_balancer?: LoadBalancerMeta; dns_resolver?: DnsResolverMeta; upstream_dns_resolution?: UpstreamDnsResolutionMeta; geoblock?: GeoBlockSettings; geoblock_mode?: GeoBlockMode; waf?: WafHostConfig; redirects?: RedirectRule[]; rewrite?: RewriteConfig; location_rules?: LocationRule[]; }; type L4Meta = { load_balancer?: LoadBalancerMeta; dns_resolver?: DnsResolverMeta; upstream_dns_resolution?: UpstreamDnsResolutionMeta; geoblock?: GeoBlockSettings; geoblock_mode?: GeoBlockMode; }; type ProxyHostAuthentikMeta = { enabled?: boolean; outpost_domain?: string; outpost_upstream?: string; auth_endpoint?: string; copy_headers?: string[]; trusted_proxies?: string[]; set_outpost_host_header?: boolean; protected_paths?: string[]; }; type AuthentikRouteConfig = { enabled: boolean; outpostDomain: string; outpostUpstream: string; authEndpoint: string; copyHeaders: string[]; trustedProxies: string[]; setOutpostHostHeader: boolean; protectedPaths: string[] | null; }; type LoadBalancerActiveHealthCheckMeta = { enabled?: boolean; uri?: string; port?: number; interval?: string; timeout?: string; status?: number; body?: string; }; type LoadBalancerPassiveHealthCheckMeta = { enabled?: boolean; fail_duration?: string; max_fails?: number; unhealthy_status?: number[]; unhealthy_latency?: string; }; type LoadBalancerMeta = { enabled?: boolean; policy?: string; policy_header_field?: string; policy_cookie_name?: string; policy_cookie_secret?: string; try_duration?: string; try_interval?: string; retries?: number; active_health_check?: LoadBalancerActiveHealthCheckMeta; passive_health_check?: LoadBalancerPassiveHealthCheckMeta; }; type LoadBalancerRouteConfig = { enabled: boolean; policy: string; policyHeaderField: string | null; policyCookieName: string | null; policyCookieSecret: string | null; tryDuration: string | null; tryInterval: string | null; retries: number | null; activeHealthCheck: { enabled: boolean; uri: string | null; port: number | null; interval: string | null; timeout: string | null; status: number | null; body: string | null; } | null; passiveHealthCheck: { enabled: boolean; failDuration: string | null; maxFails: number | null; unhealthyStatus: number[] | null; unhealthyLatency: string | null; } | null; }; type AccessListEntryRow = { accessListId: number; username: string; passwordHash: string; }; type CertificateRow = { id: number; name: string; type: string; domainNames: string; certificatePem: string | null; privateKeyPem: string | null; autoRenew: number; providerOptions: string | null; }; type CaddyHttpRoute = Record; type CertificateUsage = { certificate: CertificateRow; domains: Set; }; const VALID_UPSTREAM_DNS_FAMILIES: UpstreamDnsAddressFamily[] = ["ipv6", "ipv4", "both"]; type UpstreamDnsResolutionRouteConfig = { enabled: boolean | null; family: UpstreamDnsAddressFamily | null; }; type EffectiveUpstreamDnsResolution = { enabled: boolean; family: UpstreamDnsAddressFamily; }; 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(promise: Promise, timeoutMs: number | null, timeoutLabel: string): Promise { if (!timeoutMs || timeoutMs <= 0) { return promise; } let timeoutHandle: NodeJS.Timeout | undefined; try { return await Promise.race([ promise, new Promise((_, 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 { const errors: string[] = []; const resolved: string[] = []; const seen = new Set(); 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 { 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(); 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 collectCertificateUsage(rows: ProxyHostRow[], certificates: Map) { const usage = new Map(); const autoManagedDomains = new Set(); for (const row of rows) { if (!row.enabled) { continue; } const domains = parseJson(row.domains, []).map((domain) => domain?.trim().toLowerCase()); const filteredDomains = domains.filter((domain): domain is string => Boolean(domain)); if (filteredDomains.length === 0) { continue; } // Handle auto-managed certificates (certificateId is null) if (!row.certificateId) { for (const domain of filteredDomains) { autoManagedDomains.add(domain); } continue; } const cert = certificates.get(row.certificateId); if (!cert) { continue; } if (!usage.has(cert.id)) { usage.set(cert.id, { certificate: cert, domains: new Set() }); } const entry = usage.get(cert.id)!; for (const domain of filteredDomains) { entry.domains.add(domain); } } return { usage, autoManagedDomains }; } function mergeGeoBlockSettings( global: GeoBlockSettings, host: GeoBlockSettings ): GeoBlockSettings { return { enabled: host.enabled || global.enabled, block_countries: [...(global.block_countries ?? []), ...(host.block_countries ?? [])], block_continents: [...(global.block_continents ?? []), ...(host.block_continents ?? [])], block_asns: [...(global.block_asns ?? []), ...(host.block_asns ?? [])], block_cidrs: [...(global.block_cidrs ?? []), ...(host.block_cidrs ?? [])], block_ips: [...(global.block_ips ?? []), ...(host.block_ips ?? [])], allow_countries: [...(global.allow_countries ?? []), ...(host.allow_countries ?? [])], allow_continents: [...(global.allow_continents ?? []), ...(host.allow_continents ?? [])], allow_asns: [...(global.allow_asns ?? []), ...(host.allow_asns ?? [])], allow_cidrs: [...(global.allow_cidrs ?? []), ...(host.allow_cidrs ?? [])], allow_ips: [...(global.allow_ips ?? []), ...(host.allow_ips ?? [])], trusted_proxies: [...(global.trusted_proxies ?? []), ...(host.trusted_proxies ?? [])], // Host config wins for scalar fields fail_closed: host.fail_closed || global.fail_closed || false, response_status: host.response_status ?? global.response_status ?? 403, response_body: host.response_body ?? global.response_body ?? "Forbidden", response_headers: { ...(global.response_headers ?? {}), ...(host.response_headers ?? {}) }, redirect_url: host.redirect_url ?? global.redirect_url ?? "", }; } function resolveEffectiveGeoBlock( global: GeoBlockSettings | null, host: { geoblock: GeoBlockSettings | null; geoblock_mode: GeoBlockMode } ): GeoBlockSettings | null { const hostConfig = host.geoblock; const globalConfig = global; // Neither configured or enabled if (!hostConfig?.enabled && !globalConfig?.enabled) return null; // Host override mode: use host config only if (hostConfig && host.geoblock_mode === "override") { return hostConfig.enabled ? hostConfig : null; } // Host merge mode: merge global + host if (hostConfig && globalConfig) { return mergeGeoBlockSettings(globalConfig, hostConfig); } // Only one configured if (hostConfig?.enabled) return hostConfig; if (globalConfig?.enabled) return globalConfig; return null; } function buildBlockerHandler(config: GeoBlockSettings): Record { const handler: Record = { handler: "blocker", geoip_db: "/usr/share/GeoIP/GeoLite2-Country.mmdb", asn_db: "/usr/share/GeoIP/GeoLite2-ASN.mmdb", }; if (config.block_countries?.length) handler.block_countries = config.block_countries; if (config.block_continents?.length) handler.block_continents = config.block_continents; if (config.block_asns?.length) handler.block_asns = config.block_asns; if (config.block_cidrs?.length) handler.block_cidrs = config.block_cidrs; if (config.block_ips?.length) handler.block_ips = config.block_ips; if (config.allow_countries?.length) handler.allow_countries = config.allow_countries; if (config.allow_continents?.length) handler.allow_continents = config.allow_continents; if (config.allow_asns?.length) handler.allow_asns = config.allow_asns; if (config.allow_cidrs?.length) handler.allow_cidrs = config.allow_cidrs; if (config.allow_ips?.length) handler.allow_ips = config.allow_ips; if (config.trusted_proxies?.length) handler.trusted_proxies = expandPrivateRanges(config.trusted_proxies); if (config.fail_closed) handler.fail_closed = true; if (config.redirect_url) { handler.redirect_url = config.redirect_url; } else { if (config.response_status) handler.response_status = config.response_status; if (config.response_body) handler.response_body = config.response_body; if (config.response_headers && Object.keys(config.response_headers).length) { handler.response_headers = config.response_headers; } } return handler; } type BuildProxyRoutesOptions = { globalDnsSettings: DnsSettings | null; globalUpstreamDnsResolutionSettings: UpstreamDnsResolutionSettings | null; globalGeoBlock?: GeoBlockSettings | null; globalWaf?: WafSettings | null; mtlsRbac?: { roleFingerprintMap: Map>; certFingerprintMap: Map; accessRulesByHost: Map; }; }; export function buildLocationReverseProxy( rule: LocationRule, skipHttpsValidation: boolean, preserveHostHeader: boolean ): { safePath: string; reverseProxyHandler: Record } { const parsedTargets = rule.upstreams.map(parseUpstreamTarget); const hasHttps = parsedTargets.some((t) => t.scheme === "https"); // Sanitize path to prevent Caddy placeholder injection const safePath = rule.path.replace(/\{[^}]*\}/g, ""); const reverseProxyHandler: Record = { handler: "reverse_proxy", upstreams: parsedTargets.map((t) => ({ dial: t.dial })), }; if (preserveHostHeader) { reverseProxyHandler.headers = { request: { set: { Host: ["{http.request.host}"] } }, }; } if (hasHttps) { reverseProxyHandler.transport = { protocol: "http", tls: skipHttpsValidation ? { insecure_skip_verify: true } : {}, }; } return { safePath, reverseProxyHandler }; } async function buildProxyRoutes( rows: ProxyHostRow[], accessAccounts: Map, tlsReadyCertificates: Set, options: BuildProxyRoutesOptions ): Promise { const routes: CaddyHttpRoute[] = []; for (const row of rows) { if (!row.enabled) { continue; } // Allow hosts with certificateId = null (Caddy Auto) or with valid certificate IDs const isAutoManaged = !row.certificateId; const hasValidCertificate = row.certificateId && tlsReadyCertificates.has(row.certificateId); if (!isAutoManaged && !hasValidCertificate) { continue; } const domains = parseJson(row.domains, []); if (domains.length === 0) { continue; } const domainGroups = groupHostPatternsByPriority(domains); // Require upstreams const upstreams = parseJson(row.upstreams, []); if (upstreams.length === 0) { continue; } const handlers: Record[] = []; const meta = parseJson(row.meta, {}); const authentik = parseAuthentikConfig(meta.authentik); const cpmForwardAuth = meta.cpm_forward_auth?.enabled ? meta.cpm_forward_auth : null; const hostRoutes: CaddyHttpRoute[] = []; const effectiveGeoBlock = resolveEffectiveGeoBlock( options.globalGeoBlock ?? null, { geoblock: meta.geoblock ?? null, geoblock_mode: meta.geoblock_mode ?? "merge" } ); if (effectiveGeoBlock?.enabled) { handlers.unshift(buildBlockerHandler(effectiveGeoBlock)); } const effectiveWaf = resolveEffectiveWaf( options.globalWaf ?? null, meta.waf ); if (effectiveWaf?.enabled && effectiveWaf.mode !== 'Off') { handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allowWebsocket))); } if (row.hstsEnabled) { const value = row.hstsSubdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000"; handlers.push({ handler: "headers", response: { set: { "Strict-Transport-Security": [value] } } }); } if (row.sslForced) { for (const domainGroup of domainGroups) { hostRoutes.push({ match: [ { host: domainGroup, expression: '{http.request.scheme} == "http"' } ], handle: [ { handler: "static_response", status_code: 308, headers: { Location: ["https://{http.request.host}{http.request.uri}"] } } ], terminal: true }); } } // Structured redirects — emitted before auth so .well-known paths work without login if (meta.redirects && meta.redirects.length > 0) { const redirectRoutes = meta.redirects.map((rule) => ({ match: [{ path: [rule.from] }], handle: [{ handler: "static_response", status_code: rule.status, headers: { Location: [rule.to] }, }], })); handlers.push({ handler: "subroute", routes: redirectRoutes, }); } if (row.accessListId) { const accounts = accessAccounts.get(row.accessListId) ?? []; if (accounts.length > 0) { handlers.push({ handler: "authentication", providers: { http_basic: { accounts: accounts.map((entry) => ({ username: entry.username, password: entry.passwordHash })) } } }); } } 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 = { handler: "reverse_proxy", upstreams: resolvedUpstreams.upstreams }; // Authentik outpost handler will be added later after protected paths let outpostRoute: CaddyHttpRoute | null = null; if (authentik) { // Parse the outpost upstream URL to extract host:port for Caddy's dial field let outpostDial: string; try { const url = new URL(authentik.outpostUpstream); const port = url.port || (url.protocol === "https:" ? "443" : "80"); outpostDial = `${url.hostname}:${port}`; } catch { // If URL parsing fails, try to extract host:port from string outpostDial = authentik.outpostUpstream.replace(/^https?:\/\//, "").replace(/\/$/, ""); } const outpostHandler: Record = { handler: "reverse_proxy", upstreams: [ { dial: outpostDial } ] }; if (authentik.setOutpostHostHeader) { outpostHandler.headers = { request: { set: { Host: ["{http.reverse_proxy.upstream.host}"] } } }; } outpostRoute = { match: [ { // Sanitize outpostDomain to prevent path traversal and placeholder injection path: [`/${authentik.outpostDomain.replace(/\.\./g, '').replace(/\{[^}]*\}/g, '').replace(/\/+/g, '/')}/*`] } ], handle: [outpostHandler], terminal: true }; } if (row.preserveHostHeader) { reverseProxyHandler.headers = { request: { set: { Host: ["{http.request.host}"] } } }; } // Configure TLS transport for HTTPS upstreams if (resolvedUpstreams.hasHttpsUpstream) { const tlsTransport: Record = row.skipHttpsHostnameValidation ? { insecure_skip_verify: true } : {}; if (resolvedUpstreams.httpsTlsServerName) { tlsTransport.server_name = resolvedUpstreams.httpsTlsServerName; } reverseProxyHandler.transport = { protocol: "http", tls: tlsTransport }; } // Configure load balancing and health checks if (lbConfig) { const loadBalancing = buildLoadBalancingConfig(lbConfig); if (loadBalancing) { reverseProxyHandler.load_balancing = loadBalancing; } const healthChecks = buildHealthChecksConfig(lbConfig); if (healthChecks) { reverseProxyHandler.health_checks = healthChecks; } } // Add transport-level DNS resolver config if enabled if (dnsConfig && dnsConfig.enabled && dnsConfig.resolvers.length > 0) { const resolverConfig = buildResolverConfig(dnsConfig); if (resolverConfig) { // Merge resolver into existing transport (preserving TLS settings for HTTPS upstreams) if (reverseProxyHandler.transport) { (reverseProxyHandler.transport as Record).resolver = resolverConfig; if (dnsConfig.timeout) { (reverseProxyHandler.transport as Record).dial_timeout = dnsConfig.timeout; } } else { // No existing transport, create one with resolver reverseProxyHandler.transport = { protocol: "http", resolver: resolverConfig, ...(dnsConfig.timeout ? { dial_timeout: dnsConfig.timeout } : {}) }; } } } // Security: This field allows admins to inject arbitrary Caddy reverse_proxy config. // This is intentional — admins have full control of the proxy configuration. // Prototype pollution is prevented by mergeDeep blocking __proto__/constructor/prototype. const customReverseProxy = parseOptionalJson(meta.custom_reverse_proxy_json); if (customReverseProxy) { if (isPlainObject(customReverseProxy)) { mergeDeep(reverseProxyHandler, customReverseProxy as Record); } else { console.warn("Ignoring custom reverse proxy JSON because it is not an object", customReverseProxy); } } // Structured path prefix rewrite // Sanitize path_prefix to prevent Caddy placeholder injection if (meta.rewrite?.path_prefix) { const safePrefix = meta.rewrite.path_prefix.replace(/\{[^}]*\}/g, ''); if (safePrefix) { handlers.push({ handler: "rewrite", uri: `${safePrefix}{http.request.uri}`, }); } } // Security: This field allows admins to inject arbitrary Caddy HTTP handlers. // This is intentional — admins can add any handler (file_server, rewrite, etc.) // before the reverse_proxy handler in the chain. const customHandlers = parseCustomHandlers(meta.custom_pre_handlers_json); if (customHandlers.length > 0) { handlers.push(...customHandlers); } if (authentik) { // Build handle_response routes for copying headers on 2xx status const handleResponseRoutes: Record[] = [ { handle: [{ handler: "vars" }] } ]; // Add header copying for each configured header for (const headerName of authentik.copyHeaders) { handleResponseRoutes.push({ handle: [ { handler: "headers", request: { set: { [headerName]: [`{http.reverse_proxy.header.${headerName}}`] } } } as Record ], match: [ { not: [ { vars: { [`{http.reverse_proxy.header.${headerName}}`]: [""] } } ] } ] }); } // Create the forward auth reverse_proxy handler // Convert "private_ranges" to actual CIDR blocks for JSON config const trustedProxies = authentik.trustedProxies.includes("private_ranges") ? ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fd00::/8", "::1/128"] : authentik.trustedProxies; // Parse the outpost upstream to extract host:port for dial // Remove http://, https://, and any trailing slashes let dialAddress = authentik.outpostUpstream.replace(/^https?:\/\//, "").replace(/\/$/, ""); // Remove any path portion if accidentally included dialAddress = dialAddress.split("/")[0]; const forwardAuthHandler: Record = { handler: "reverse_proxy", upstreams: [ { dial: dialAddress } ], rewrite: { method: "GET", uri: authentik.authEndpoint }, headers: { request: { set: { "X-Forwarded-Method": ["{http.request.method}"], "X-Forwarded-Uri": ["{http.request.uri}"] } } }, handle_response: [ { match: { status_code: [2] }, routes: handleResponseRoutes } ] }; if (trustedProxies.length > 0) { forwardAuthHandler.trusted_proxies = trustedProxies; } // Path-based authentication support if (authentik.protectedPaths && authentik.protectedPaths.length > 0) { for (const domainGroup of domainGroups) { // Create separate routes for each protected path for (const protectedPath of authentik.protectedPaths) { const protectedHandlers: Record[] = [...handlers]; const protectedReverseProxy = JSON.parse(JSON.stringify(reverseProxyHandler)); protectedHandlers.push(forwardAuthHandler); protectedHandlers.push(protectedReverseProxy); hostRoutes.push({ match: [ { host: domainGroup, path: [protectedPath] } ], handle: protectedHandlers, terminal: true }); } if (outpostRoute) { const outpostMatches = (outpostRoute.match as Array> | undefined) ?? []; hostRoutes.push({ ...outpostRoute, match: outpostMatches.map((match) => ({ ...match, host: domainGroup })) }); } // Location rules are unprotected (no forwardAuthHandler), matching the catch-all // behavior when protected_paths is configured — only explicitly protected paths get auth. const locationRules = meta.location_rules ?? []; for (const rule of locationRules) { const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy( rule, Boolean(row.skipHttpsHostnameValidation), Boolean(row.preserveHostHeader) ); if (!safePath) continue; hostRoutes.push({ match: [{ host: domainGroup, path: [safePath] }], handle: [...handlers, locationProxy], terminal: true, }); } const unprotectedHandlers: Record[] = [...handlers, reverseProxyHandler]; hostRoutes.push({ match: [{ host: domainGroup }], handle: unprotectedHandlers, terminal: true }); } } else { const locationRules = meta.location_rules ?? []; for (const domainGroup of domainGroups) { if (outpostRoute) { const outpostMatches = (outpostRoute.match as Array> | undefined) ?? []; hostRoutes.push({ ...outpostRoute, match: outpostMatches.map((match) => ({ ...match, host: domainGroup })) }); } for (const rule of locationRules) { const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy( rule, Boolean(row.skipHttpsHostnameValidation), Boolean(row.preserveHostHeader) ); if (!safePath) continue; hostRoutes.push({ match: [{ host: domainGroup, path: [safePath] }], handle: [...handlers, forwardAuthHandler, locationProxy], terminal: true, }); } const routeHandlers: Record[] = [...handlers, forwardAuthHandler, reverseProxyHandler]; const route: CaddyHttpRoute = { match: [{ host: domainGroup }], handle: routeHandlers, terminal: true }; hostRoutes.push(route); } } } else if (cpmForwardAuth) { // ── CPM Forward Auth ──────────────────────────────────────────── // Uses CPM itself as the auth provider (replaces Authentik) const cpmDialAddress = getCpmDialAddress(); if (cpmDialAddress) { const CPM_COPY_HEADERS = [ "X-CPM-User", "X-CPM-Email", "X-CPM-Groups", "X-CPM-User-Id" ]; // Build handle_response routes for copying user headers on 2xx const cpmHandleResponseRoutes: Record[] = [ { handle: [{ handler: "vars" }] } ]; for (const headerName of CPM_COPY_HEADERS) { cpmHandleResponseRoutes.push({ handle: [ { handler: "headers", request: { set: { [headerName]: [`{http.reverse_proxy.header.${headerName}}`] } } } as Record ], match: [ { not: [{ vars: { [`{http.reverse_proxy.header.${headerName}}`]: [""] } }] } ] }); } // Forward auth handler — subrequest to CPM verify endpoint const cpmForwardAuthHandler: Record = { handler: "reverse_proxy", upstreams: [{ dial: cpmDialAddress }], rewrite: { method: "GET", uri: "/api/forward-auth/verify" }, headers: { request: { set: { "X-Forwarded-Method": ["{http.request.method}"], "X-Forwarded-Uri": ["{http.request.uri}"], "X-Forwarded-Host": ["{http.request.host}"], "X-Forwarded-Proto": ["{http.request.scheme}"] } } }, handle_response: [ { match: { status_code: [2] }, routes: cpmHandleResponseRoutes }, { match: { status_code: [401, 403] }, routes: [ { handle: [ { handler: "static_response", status_code: 302, headers: { Location: [ `${config.baseUrl}/portal?rd={http.request.scheme}://{http.request.host}{http.request.uri}` ] } } ] } ] } ], trusted_proxies: ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fd00::/8", "::1/128"] }; // Callback route — unprotected, so it goes before forward_auth const cpmCallbackRoute: CaddyHttpRoute = { match: [{ path: ["/.cpm-auth/callback"] }], handle: [ { handler: "reverse_proxy", upstreams: [{ dial: cpmDialAddress }], rewrite: { uri: "/api/forward-auth/callback?{http.request.uri.query}" }, headers: { request: { set: { "X-Forwarded-Host": ["{http.request.host}"], "X-Forwarded-Proto": ["{http.request.scheme}"] } } } } ], terminal: true }; const locationRules = meta.location_rules ?? []; if (cpmForwardAuth.protected_paths && cpmForwardAuth.protected_paths.length > 0) { // Path-specific authentication for (const domainGroup of domainGroups) { // Add callback route (unprotected) hostRoutes.push({ ...cpmCallbackRoute, match: [{ host: domainGroup, path: ["/.cpm-auth/callback"] }] }); // Protected paths for (const protectedPath of cpmForwardAuth.protected_paths) { const protectedHandlers: Record[] = [...handlers]; const protectedReverseProxy = JSON.parse(JSON.stringify(reverseProxyHandler)); protectedHandlers.push(cpmForwardAuthHandler); protectedHandlers.push(protectedReverseProxy); hostRoutes.push({ match: [{ host: domainGroup, path: [protectedPath] }], handle: protectedHandlers, terminal: true }); } // Location rules (unprotected) for (const rule of locationRules) { const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy( rule, Boolean(row.skipHttpsHostnameValidation), Boolean(row.preserveHostHeader) ); if (!safePath) continue; hostRoutes.push({ match: [{ host: domainGroup, path: [safePath] }], handle: [...handlers, locationProxy], terminal: true }); } // Unprotected catch-all hostRoutes.push({ match: [{ host: domainGroup }], handle: [...handlers, reverseProxyHandler], terminal: true }); } } else { // Protect entire site for (const domainGroup of domainGroups) { // Callback route first (unprotected) hostRoutes.push({ ...cpmCallbackRoute, match: [{ host: domainGroup, path: ["/.cpm-auth/callback"] }] }); // Location rules with forward auth for (const rule of locationRules) { const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy( rule, Boolean(row.skipHttpsHostnameValidation), Boolean(row.preserveHostHeader) ); if (!safePath) continue; hostRoutes.push({ match: [{ host: domainGroup, path: [safePath] }], handle: [...handlers, cpmForwardAuthHandler, locationProxy], terminal: true }); } // Main route with forward auth hostRoutes.push({ match: [{ host: domainGroup }], handle: [...handlers, cpmForwardAuthHandler, reverseProxyHandler], terminal: true }); } } } } else { const locationRules = meta.location_rules ?? []; // Check for mTLS RBAC access rules for this proxy host const hostAccessRules = options.mtlsRbac?.accessRulesByHost.get(row.id); const hasMtlsRbac = hostAccessRules && hostAccessRules.length > 0 && options.mtlsRbac?.roleFingerprintMap && options.mtlsRbac?.certFingerprintMap; for (const domainGroup of domainGroups) { for (const rule of locationRules) { const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy( rule, Boolean(row.skipHttpsHostnameValidation), Boolean(row.preserveHostHeader) ); if (!safePath) continue; hostRoutes.push({ match: [{ host: domainGroup, path: [safePath] }], handle: [...handlers, locationProxy], terminal: true, }); } if (hasMtlsRbac) { // mTLS RBAC: wrap in subroute with path-based fingerprint enforcement const rbacSubroutes = buildMtlsRbacSubroutes( hostAccessRules, options.mtlsRbac!.roleFingerprintMap, options.mtlsRbac!.certFingerprintMap, handlers, reverseProxyHandler ); if (rbacSubroutes) { hostRoutes.push({ match: [{ host: domainGroup }], handle: [{ handler: "subroute", routes: rbacSubroutes, }], terminal: true, }); } else { // Fallback: no subroutes generated, use normal routing hostRoutes.push({ match: [{ host: domainGroup }], handle: [...handlers, reverseProxyHandler], terminal: true, }); } } else { const route: CaddyHttpRoute = { match: [{ host: domainGroup }], handle: [...handlers, reverseProxyHandler], terminal: true, }; hostRoutes.push(route); } } } routes.push(...hostRoutes); } return sortRoutesByHostPriority(routes); } function buildTlsConnectionPolicies( usage: Map, managedCertificatesWithAutomation: Set, autoManagedDomains: Set, mTlsDomainMap: Map, caCertMap: Map, issuedClientCertMap: Map, cAsWithAnyIssuedCerts: Set, mTlsDomainLeafOverride: Map ) { const policies: Record[] = []; const readyCertificates = new Set(); const importedCertPems: { certificate: string; key: string }[] = []; const buildAuth = (domains: string[]) => buildClientAuthentication(domains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts, mTlsDomainLeafOverride); /** * Pushes one TLS policy per unique CA set found in `mTlsDomains`. * Domains that share the same CA configuration are grouped into one policy; * domains with different CAs get separate policies so a cert from CA_B cannot * authenticate against a host that only trusts CA_A. */ const pushMtlsPolicies = (mTlsDomains: string[]) => { const groups = groupMtlsDomainsByCaSet(mTlsDomains, mTlsDomainMap); for (const domainGroup of groups.values()) { for (const priorityGroup of groupHostPatternsByPriority(domainGroup)) { const mTlsAuth = buildAuth(priorityGroup); if (mTlsAuth) { policies.push({ match: { sni: priorityGroup }, client_authentication: mTlsAuth }); } else { // All CAs have all certs revoked — drop connections rather than allow through without mTLS policies.push({ match: { sni: priorityGroup }, drop: true }); } } } }; // Add policy for auto-managed domains (certificateId = null) if (autoManagedDomains.size > 0) { const domains = Array.from(autoManagedDomains); // Split first so mTLS domains always get their own policy, regardless of auth result. const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d)); const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d)); if (mTlsDomains.length > 0) { pushMtlsPolicies(mTlsDomains); } for (const priorityGroup of groupHostPatternsByPriority(nonMTlsDomains)) { policies.push({ match: { sni: priorityGroup } }); } } for (const [id, entry] of usage.entries()) { const domains = Array.from(entry.domains); if (domains.length === 0) { continue; } if (entry.certificate.type === "imported") { if (!entry.certificate.certificatePem || !entry.certificate.privateKeyPem) { continue; } // Collect PEMs for tls.certificates.load_pem (inline, no shared filesystem needed) importedCertPems.push({ certificate: entry.certificate.certificatePem.trim(), key: entry.certificate.privateKeyPem.trim() }); const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d)); const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d)); if (mTlsDomains.length > 0) { pushMtlsPolicies(mTlsDomains); } for (const priorityGroup of groupHostPatternsByPriority(nonMTlsDomains)) { policies.push({ match: { sni: priorityGroup } }); } readyCertificates.add(id); continue; } if (entry.certificate.type === "managed") { if (!managedCertificatesWithAutomation.has(id)) { continue; } const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d)); const nonMTlsDomains = domains.filter(d => !mTlsDomainMap.has(d)); if (mTlsDomains.length > 0) { pushMtlsPolicies(mTlsDomains); } for (const priorityGroup of groupHostPatternsByPriority(nonMTlsDomains)) { policies.push({ match: { sni: priorityGroup } }); } readyCertificates.add(id); } } return { policies: sortTlsPoliciesBySniPriority(policies), readyCertificates, importedCertPems }; } async function buildTlsAutomation( usage: Map, autoManagedDomains: Set, options: { acmeEmail?: string; dnsSettings?: DnsSettings | null } ) { const managedEntries = Array.from(usage.values()).filter( (entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.autoRenew) ); const hasAutoManagedDomains = autoManagedDomains.size > 0; if (managedEntries.length === 0 && !hasAutoManagedDomains) { return { managedCertificateIds: new Set() }; } const cloudflare = await getCloudflareSettings(); const hasCloudflare = cloudflare && cloudflare.apiToken; const dnsSettings = options.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[] = []; // Add policy for auto-managed domains (certificateId = null) if (hasAutoManagedDomains) { for (const subjects of groupHostPatternsByPriority(Array.from(autoManagedDomains))) { const issuer: Record = { module: "acme" }; if (options.acmeEmail) { issuer.email = options.acmeEmail; } if (hasCloudflare) { const providerConfig: Record = { name: "cloudflare", api_token: cloudflare.apiToken }; const dnsChallenge: Record = { provider: providerConfig }; if (dnsResolvers.length > 0) { dnsChallenge.resolvers = dnsResolvers; } issuer.challenges = { dns: dnsChallenge }; } policies.push({ subjects, issuers: [issuer] }); } } // Add policies for explicitly managed certificates for (const entry of managedEntries) { const subjects = Array.from(entry.domains); if (subjects.length === 0) { continue; } managedCertificateIds.add(entry.certificate.id); for (const subjectGroup of groupHostPatternsByPriority(subjects)) { const issuer: Record = { module: "acme" }; if (options.acmeEmail) { issuer.email = options.acmeEmail; } if (hasCloudflare) { const providerConfig: Record = { name: "cloudflare", api_token: cloudflare.apiToken }; const dnsChallenge: Record = { provider: providerConfig }; if (dnsResolvers.length > 0) { dnsChallenge.resolvers = dnsResolvers; } issuer.challenges = { dns: dnsChallenge }; } policies.push({ subjects: subjectGroup, issuers: [issuer] }); } } if (policies.length === 0) { return { managedCertificateIds }; } return { tlsApp: { automation: { policies: sortAutomationPoliciesBySubjectPriority(policies) } }, managedCertificateIds }; } async function buildL4Servers(): Promise | null> { const l4Hosts = await db .select() .from(l4ProxyHosts) .where(eq(l4ProxyHosts.enabled, true)); if (l4Hosts.length === 0) return null; const [globalDnsSettings, globalUpstreamDnsResolutionSettings, globalGeoBlock] = await Promise.all([ getDnsSettings(), getUpstreamDnsResolutionSettings(), getGeoBlockSettings(), ]); // Group hosts by listen address — multiple hosts on the same port share routes in one server const serverMap = new Map(); for (const host of l4Hosts) { const key = host.listenAddress; if (!serverMap.has(key)) serverMap.set(key, []); serverMap.get(key)!.push(host); } const servers: Record = {}; let serverIdx = 0; for (const [listenAddr, hosts] of serverMap) { const routes: Record[] = []; for (const host of hosts) { const route: Record = {}; // Build matchers const matcherType = host.matcherType as string; const matcherValues = host.matcherValue ? parseJson(host.matcherValue, []) : []; if (matcherType === "tls_sni" && matcherValues.length > 0) { route.match = [{ tls: { sni: matcherValues } }]; } else if (matcherType === "http_host" && matcherValues.length > 0) { route.match = [{ http: [{ host: matcherValues }] }]; } else if (matcherType === "proxy_protocol") { route.match = [{ proxy_protocol: {} }]; } // "none" = no match block (catch-all) // Parse per-host meta for load balancing, DNS resolver, and upstream DNS resolution const meta = parseJson(host.meta, {}); // Load balancer config const lbMeta = meta.load_balancer; let lbConfig: LoadBalancerRouteConfig | null = null; if (lbMeta?.enabled) { lbConfig = { enabled: true, policy: lbMeta.policy ?? "random", policyHeaderField: null, policyCookieName: null, policyCookieSecret: null, tryDuration: lbMeta.try_duration ?? null, tryInterval: lbMeta.try_interval ?? null, retries: lbMeta.retries ?? null, activeHealthCheck: lbMeta.active_health_check?.enabled ? { enabled: true, uri: null, port: lbMeta.active_health_check.port ?? null, interval: lbMeta.active_health_check.interval ?? null, timeout: lbMeta.active_health_check.timeout ?? null, status: null, body: null, } : null, passiveHealthCheck: lbMeta.passive_health_check?.enabled ? { enabled: true, failDuration: lbMeta.passive_health_check.fail_duration ?? null, maxFails: lbMeta.passive_health_check.max_fails ?? null, unhealthyStatus: null, unhealthyLatency: lbMeta.passive_health_check.unhealthy_latency ?? null, } : null, }; } // DNS resolver config const dnsConfig = parseDnsResolverConfig(meta.dns_resolver); // Upstream DNS resolution (pinning) const hostDnsResolution = parseUpstreamDnsResolutionConfig(meta.upstream_dns_resolution); const effectiveDnsResolution = resolveEffectiveUpstreamDnsResolution( globalUpstreamDnsResolutionSettings, hostDnsResolution ); // Build handler chain const handlers: Record[] = []; // 1. Receive inbound proxy protocol if (host.proxyProtocolReceive) { handlers.push({ handler: "proxy_protocol" }); } // 2. TLS termination if (host.tlsTermination) { handlers.push({ handler: "tls" }); } // 3. Proxy handler const upstreams = parseJson(host.upstreams, []); // Resolve upstream hostnames to IPs if DNS pinning is enabled let resolvedDials = upstreams; if (effectiveDnsResolution.enabled) { const resolver = new Resolver(); const lookupServers = getLookupServers(dnsConfig, globalDnsSettings); if (lookupServers.length > 0) { try { resolver.setServers(lookupServers); } catch { /* ignore invalid servers */ } } const timeoutMs = getLookupTimeoutMs(dnsConfig, globalDnsSettings); const pinned: string[] = []; for (const upstream of upstreams) { const colonIdx = upstream.lastIndexOf(":"); if (colonIdx <= 0) { pinned.push(upstream); continue; } const hostPart = upstream.substring(0, colonIdx); const portPart = upstream.substring(colonIdx + 1); if (isIP(hostPart) !== 0) { pinned.push(upstream); continue; } try { const addresses = await resolveHostnameAddresses(resolver, hostPart, effectiveDnsResolution.family, timeoutMs); for (const addr of addresses) { pinned.push(addr.includes(":") ? `[${addr}]:${portPart}` : `${addr}:${portPart}`); } } catch { pinned.push(upstream); } } resolvedDials = pinned; } // For UDP hosts, upstream dials must also use the udp/ prefix const dialPrefix = (host.protocol as string) === "udp" ? "udp/" : ""; const proxyHandler: Record = { handler: "proxy", upstreams: resolvedDials.map((u) => ({ dial: [`${dialPrefix}${u}`] })), }; if (host.proxyProtocolVersion) { proxyHandler.proxy_protocol = host.proxyProtocolVersion; } if (lbConfig) { const loadBalancing = buildLoadBalancingConfig(lbConfig); if (loadBalancing) proxyHandler.load_balancing = loadBalancing; const healthChecks = buildHealthChecksConfig(lbConfig); if (healthChecks) proxyHandler.health_checks = healthChecks; } handlers.push(proxyHandler); route.handle = handlers; // Geo blocking: add a blocking route BEFORE the proxy route. // At L4, the blocker is a matcher (layer4.matchers.blocker) — blocked connections // match this route and are closed. Non-blocked connections fall through to the proxy route. const effectiveGeoBlock = resolveEffectiveGeoBlock(globalGeoBlock, { geoblock: meta.geoblock ?? null, geoblock_mode: meta.geoblock_mode ?? "merge", }); if (effectiveGeoBlock) { const blockerMatcher: Record = { geoip_db: "/usr/share/GeoIP/GeoLite2-Country.mmdb", asn_db: "/usr/share/GeoIP/GeoLite2-ASN.mmdb", }; if (effectiveGeoBlock.block_countries?.length) blockerMatcher.block_countries = effectiveGeoBlock.block_countries; if (effectiveGeoBlock.block_continents?.length) blockerMatcher.block_continents = effectiveGeoBlock.block_continents; if (effectiveGeoBlock.block_asns?.length) blockerMatcher.block_asns = effectiveGeoBlock.block_asns; if (effectiveGeoBlock.block_cidrs?.length) blockerMatcher.block_cidrs = effectiveGeoBlock.block_cidrs; if (effectiveGeoBlock.block_ips?.length) blockerMatcher.block_ips = effectiveGeoBlock.block_ips; if (effectiveGeoBlock.allow_countries?.length) blockerMatcher.allow_countries = effectiveGeoBlock.allow_countries; if (effectiveGeoBlock.allow_continents?.length) blockerMatcher.allow_continents = effectiveGeoBlock.allow_continents; if (effectiveGeoBlock.allow_asns?.length) blockerMatcher.allow_asns = effectiveGeoBlock.allow_asns; if (effectiveGeoBlock.allow_cidrs?.length) blockerMatcher.allow_cidrs = effectiveGeoBlock.allow_cidrs; if (effectiveGeoBlock.allow_ips?.length) blockerMatcher.allow_ips = effectiveGeoBlock.allow_ips; // Build the same route matcher as the proxy route (if any) const blockRoute: Record = { match: [ { blocker: blockerMatcher, ...(route.match ? (route.match as Record[])[0] : {}), }, ], handle: [{ handler: "close" }], }; routes.push(blockRoute); } routes.push(route); } // Determine protocol from the hosts on this listen address. // All hosts sharing a listen address must use the same protocol. const protocol = hosts[0].protocol as string; const listenValue = protocol === "udp" ? `udp/${listenAddr}` : listenAddr; servers[`l4_server_${serverIdx++}`] = { listen: [listenValue], routes, }; } return servers; } async function buildCaddyDocument() { const [proxyHostRecords, certRows, accessListEntryRecords, caCertRows, issuedClientCertRows, allIssuedCaCertIds, allIssuedCertCaMap] = await Promise.all([ db .select({ id: proxyHosts.id, name: proxyHosts.name, domains: proxyHosts.domains, upstreams: proxyHosts.upstreams, certificateId: proxyHosts.certificateId, accessListId: proxyHosts.accessListId, sslForced: proxyHosts.sslForced, hstsEnabled: proxyHosts.hstsEnabled, hstsSubdomains: proxyHosts.hstsSubdomains, allowWebsocket: proxyHosts.allowWebsocket, preserveHostHeader: proxyHosts.preserveHostHeader, skipHttpsHostnameValidation: proxyHosts.skipHttpsHostnameValidation, meta: proxyHosts.meta, enabled: proxyHosts.enabled }) .from(proxyHosts), db .select({ id: certificates.id, name: certificates.name, type: certificates.type, domainNames: certificates.domainNames, certificatePem: certificates.certificatePem, privateKeyPem: certificates.privateKeyPem, autoRenew: certificates.autoRenew, providerOptions: certificates.providerOptions }) .from(certificates), db .select({ accessListId: accessListEntries.accessListId, username: accessListEntries.username, passwordHash: accessListEntries.passwordHash }) .from(accessListEntries), db .select({ id: caCertificates.id, certificatePem: caCertificates.certificatePem }) .from(caCertificates), db .select({ id: issuedClientCertificates.id, caCertificateId: issuedClientCertificates.caCertificateId, certificatePem: issuedClientCertificates.certificatePem }) .from(issuedClientCertificates) .where(isNull(issuedClientCertificates.revokedAt)), // Distinct CA IDs that have ever had a tracked issued cert (including revoked). // Used to distinguish "managed" CAs (pin to leaf certs) from "unmanaged" CAs // (trust any cert signed by that CA). db .selectDistinct({ caCertificateId: issuedClientCertificates.caCertificateId }) .from(issuedClientCertificates), // All issued certs (including revoked) — cert ID → CA ID only. // Used to derive CA IDs for the new trust model even when all certs are revoked, // so the domain stays in mTlsDomainMap and gets a fail-closed mTLS policy. db .select({ id: issuedClientCertificates.id, caCertificateId: issuedClientCertificates.caCertificateId }) .from(issuedClientCertificates) ]); const proxyHostRows: ProxyHostRow[] = proxyHostRecords.map((h) => ({ id: h.id, name: h.name, domains: h.domains, upstreams: h.upstreams, certificateId: h.certificateId, accessListId: h.accessListId, sslForced: h.sslForced ? 1 : 0, hstsEnabled: h.hstsEnabled ? 1 : 0, hstsSubdomains: h.hstsSubdomains ? 1 : 0, allowWebsocket: h.allowWebsocket ? 1 : 0, preserveHostHeader: h.preserveHostHeader ? 1 : 0, skipHttpsHostnameValidation: h.skipHttpsHostnameValidation ? 1 : 0, meta: h.meta, enabled: h.enabled ? 1 : 0 })); const certRowsMapped: CertificateRow[] = certRows.map((c: typeof certRows[0]) => ({ id: c.id, name: c.name, type: c.type as "managed" | "imported", domainNames: c.domainNames, certificatePem: c.certificatePem, privateKeyPem: c.privateKeyPem, autoRenew: c.autoRenew ? 1 : 0, providerOptions: c.providerOptions })); const accessListEntryRows: AccessListEntryRow[] = accessListEntryRecords.map((entry) => ({ accessListId: entry.accessListId, username: entry.username, passwordHash: entry.passwordHash })); const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert])); const caCertMap = new Map(caCertRows.map((ca) => [ca.id, ca])); const issuedClientCertMap = issuedClientCertRows.reduce>((map, record) => { const current = map.get(record.caCertificateId) ?? []; current.push(record.certificatePem); map.set(record.caCertificateId, current); return map; }, new Map()); const cAsWithAnyIssuedCerts = new Set(allIssuedCaCertIds.map(r => r.caCertificateId)); const accessMap = accessListEntryRows.reduce>((map, entry) => { if (!map.has(entry.accessListId)) { map.set(entry.accessListId, []); } map.get(entry.accessListId)!.push(entry); return map; }, new Map()); // Build a lookup: issued cert ID → { id, caCertificateId, certificatePem } (active only) const issuedCertById = new Map(issuedClientCertRows.map(r => [r.id, r])); // Cert ID → CA ID for ALL certs (including revoked), used to derive CA IDs for fail-closed const certIdToCaId = new Map(allIssuedCertCaMap.map(r => [r.id, r.caCertificateId])); // Resolve role IDs → cert IDs for trusted_role_ids in mTLS config const roleCertIdMap = await buildRoleCertIdMap(); // Build domain → CA cert IDs map for mTLS-enabled hosts. // New model (trusted_client_cert_ids + trusted_role_ids): derive CAs from selected certs and pin to those certs. // Old model (ca_certificate_ids): trust entire CAs as before. const mTlsDomainMap = new Map(); // Per-domain override: which specific leaf cert PEMs to pin (new model only) const mTlsDomainLeafOverride = new Map(); for (const row of proxyHostRows) { if (!row.enabled) continue; const meta = parseJson<{ mtls?: MtlsConfig }>(row.meta, {}); if (!meta.mtls?.enabled) continue; const domains = parseJson(row.domains, []).map(d => d.trim().toLowerCase()).filter(Boolean); if (domains.length === 0) continue; // Collect all trusted cert IDs from both direct selection and roles const allCertIds = new Set(); if (meta.mtls.trusted_client_cert_ids) { for (const id of meta.mtls.trusted_client_cert_ids) allCertIds.add(id); } if (meta.mtls.trusted_role_ids) { for (const roleId of meta.mtls.trusted_role_ids) { const certIds = roleCertIdMap.get(roleId); if (certIds) for (const id of certIds) allCertIds.add(id); } } if (allCertIds.size > 0) { // New model: derive CAs from resolved cert IDs and collect leaf PEMs const derivedCaIds = new Set(); const leafPems: string[] = []; for (const certId of allCertIds) { const cert = issuedCertById.get(certId); if (cert) { derivedCaIds.add(cert.caCertificateId); leafPems.push(cert.certificatePem); } } if (derivedCaIds.size === 0) { // All referenced certs are revoked — derive CAs from the full cert map // (including revoked) so the domain stays in mTlsDomainMap and gets a // fail-closed mTLS policy via buildClientAuthentication. for (const certId of allCertIds) { const caId = certIdToCaId.get(certId); if (caId !== undefined) derivedCaIds.add(caId); } if (derivedCaIds.size === 0) continue; } const caIdArr = Array.from(derivedCaIds); for (const domain of domains) { mTlsDomainMap.set(domain, caIdArr); if (leafPems.length > 0) { mTlsDomainLeafOverride.set(domain, leafPems); } } } else if (meta.mtls.ca_certificate_ids?.length) { // Legacy model: trust entire CAs (backward compat) for (const domain of domains) { mTlsDomainMap.set(domain, meta.mtls.ca_certificate_ids); } } } // Build mTLS RBAC data for HTTP-layer enforcement const enabledProxyHostIds = proxyHostRows.filter((r) => r.enabled).map((r) => r.id); const [roleFingerprintMap, certFingerprintMap, accessRulesByHost] = await Promise.all([ buildRoleFingerprintMap(), buildCertFingerprintMap(), getAccessRulesForHosts(enabledProxyHostIds), ]); const { usage: certificateUsage, autoManagedDomains } = collectCertificateUsage(proxyHostRows, certificateMap); const [generalSettings, dnsSettings, upstreamDnsResolutionSettings, globalGeoBlock, globalWaf] = await Promise.all([ getGeneralSettings(), getDnsSettings(), getUpstreamDnsResolutionSettings(), getGeoBlockSettings(), getWafSettings() ]); const { tlsApp, managedCertificateIds } = await buildTlsAutomation(certificateUsage, autoManagedDomains, { acmeEmail: generalSettings?.acmeEmail, dnsSettings }); const { policies: tlsConnectionPolicies, readyCertificates, importedCertPems } = buildTlsConnectionPolicies( certificateUsage, managedCertificateIds, autoManagedDomains, mTlsDomainMap, caCertMap, issuedClientCertMap, cAsWithAnyIssuedCerts, mTlsDomainLeafOverride ); const httpRoutes: CaddyHttpRoute[] = await buildProxyRoutes( proxyHostRows, accessMap, readyCertificates, { globalDnsSettings: dnsSettings, globalUpstreamDnsResolutionSettings: upstreamDnsResolutionSettings, globalGeoBlock, globalWaf, mtlsRbac: { roleFingerprintMap, certFingerprintMap, accessRulesByHost, }, } ); const hasTls = tlsConnectionPolicies.length > 0; // Check if metrics should be enabled const metricsSettings = await getMetricsSettings(); const metricsEnabled = metricsSettings?.enabled ?? false; const metricsPort = metricsSettings?.port ?? 9090; // Check if access logging should be enabled const loggingSettings = await getLoggingSettings(); const loggingEnabled = loggingSettings?.enabled ?? false; const loggingFormat = loggingSettings?.format ?? "json"; const servers: Record = {}; // Main HTTP/HTTPS server for proxy hosts if (httpRoutes.length > 0) { servers.cpm = { listen: hasTls ? [":80", ":443"] : [":80"], routes: httpRoutes, // Only disable automatic HTTPS if we have TLS automation policies // This allows Caddy to handle HTTP-01 challenges for managed certificates ...(tlsApp ? {} : { automatic_https: { disable: true } }), ...(hasTls ? { tls_connection_policies: tlsConnectionPolicies } : {}), // Enable access logging if configured ...(loggingEnabled ? { logs: { default_logger_name: "http_access" } } : {}) }; } // Metrics server - exposes /metrics endpoint on separate port if (metricsEnabled) { servers.metrics = { listen: [`:${metricsPort}`], routes: [ { handle: [ { handler: "reverse_proxy", upstreams: [{ dial: "localhost:2019" }], rewrite: { uri: "/metrics" } } ] } ] }; } const httpApp = Object.keys(servers).length > 0 ? { http: { servers } } : {}; // Build logging configuration const loggingLogs: Record = { // Always capture WAF rule match logs so the waf-log-parser can extract rule details. // Coraza does not write matched rules to the audit log (known bug), but it does emit // structured JSON lines via the http.handlers.waf logger for each matched rule. waf_rules: { writer: { output: "file", filename: "/logs/waf-rules.log", mode: "0640" }, encoder: { format: "json" }, include: ["http.handlers.waf"], level: "ERROR" } }; if (loggingEnabled) { loggingLogs.http_access = { writer: { output: "file", filename: "/logs/access.log", mode: "0640" }, encoder: { format: loggingFormat }, include: ["http.log.access", "http.handlers.blocker"] }; } const loggingApp = { logging: { logs: loggingLogs } }; // Build L4 (TCP/UDP) proxy servers const l4Servers = await buildL4Servers(); const l4App = l4Servers ? { layer4: { servers: l4Servers } } : {}; return { admin: { listen: "0.0.0.0:2019", origins: ["caddy:2019", "localhost:2019", "localhost"] }, ...loggingApp, apps: { ...httpApp, ...(tlsApp || importedCertPems.length > 0 ? { tls: { ...(tlsApp ?? {}), ...(importedCertPems.length > 0 ? { certificates: { load_pem: importedCertPems } } : {}) } } : {}), ...l4App } }; } /** * Plain HTTP/HTTPS request to the Caddy admin API using node:http. * Avoids browser-security headers (Sec-Fetch-*) that native fetch sends, * which would trigger Caddy's CORS origin enforcement. */ function caddyRequest(url: string, method: string, body?: string): Promise<{ status: number; text: string }> { return new Promise((resolve, reject) => { const parsed = new URL(url); const lib = parsed.protocol === "https:" ? https : http; const req = lib.request( { hostname: parsed.hostname, port: parsed.port, path: parsed.pathname + parsed.search, method, headers: { ...(body ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) } : {}) } }, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => resolve({ status: res.statusCode ?? 0, text: data })); } ); req.on("error", reject); if (body) req.write(body); req.end(); }); } export async function applyCaddyConfig() { const document = await buildCaddyDocument(); const payload = JSON.stringify(document); const hash = crypto.createHash("sha256").update(payload).digest("hex"); setSetting("caddy_config_hash", { hash, updatedAt: nowIso() }); try { const response = await caddyRequest(`${config.caddyApiUrl}/load`, "POST", payload); if (response.status < 200 || response.status >= 300) { throw new Error(`Caddy config load failed: ${response.status} ${response.text}`); } await syncInstances(); } catch (error) { console.error("Failed to apply Caddy config", error); // Check if it's a fetch error with ECONNREFUSED or ENOTFOUND const err = error as { cause?: NodeJS.ErrnoException }; const causeCode = err?.cause?.code; if (causeCode === "ENOTFOUND" || causeCode === "ECONNREFUSED") { throw new Error( `Unable to reach Caddy API at ${config.caddyApiUrl}. Ensure Caddy is running and accessible.`, { cause: error } ); } throw error; } } /** * Derives the dial address (host:port) for Caddy to reach CPM internally. * Uses FORWARD_AUTH_INTERNAL_URL env var if set. Otherwise, if CADDY_API_URL * points to a Docker service name (e.g. "caddy:2019"), assumes Docker networking * and defaults to "web:3000". Falls back to deriving from BASE_URL. */ function getCpmDialAddress(): string | null { const internalUrl = config.forwardAuthInternalUrl; if (internalUrl) { // Strip protocol, trailing slashes, and paths return internalUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); } // If CADDY_API_URL uses a Docker service name, assume Docker networking // and use the web service name directly try { const caddyUrl = new URL(config.caddyApiUrl); if (caddyUrl.hostname !== "localhost" && caddyUrl.hostname !== "127.0.0.1" && caddyUrl.hostname !== "::1") { // Caddy is on a Docker network — CPM is the "web" service on port 3000 return "web:3000"; } } catch { // ignore } // Derive from BASE_URL (works for non-Docker setups) try { const url = new URL(config.baseUrl); const port = url.port || (url.protocol === "https:" ? "443" : "80"); return `${url.hostname}:${port}`; } catch { return null; } } function parseAuthentikConfig(meta: ProxyHostAuthentikMeta | undefined | null): AuthentikRouteConfig | null { if (!meta || !meta.enabled) { return null; } const outpostDomain = typeof meta.outpost_domain === "string" ? meta.outpost_domain.trim() : ""; const outpostUpstream = typeof meta.outpost_upstream === "string" ? meta.outpost_upstream.trim() : ""; if (!outpostDomain || !outpostUpstream) { return null; } const authEndpointRaw = typeof meta.auth_endpoint === "string" ? meta.auth_endpoint.trim() : ""; const authEndpoint = authEndpointRaw || `/${outpostDomain}/auth/caddy`; const copyHeaders = Array.isArray(meta.copy_headers) && meta.copy_headers.length > 0 ? meta.copy_headers.map((header) => header?.trim()).filter((header): header is string => Boolean(header)) : DEFAULT_AUTHENTIK_HEADERS; const trustedProxies = Array.isArray(meta.trusted_proxies) && meta.trusted_proxies.length > 0 ? meta.trusted_proxies.map((item) => item?.trim()).filter((item): item is string => Boolean(item)) : DEFAULT_AUTHENTIK_TRUSTED_PROXIES; const setOutpostHostHeader = meta.set_outpost_host_header !== undefined ? Boolean(meta.set_outpost_host_header) : true; const protectedPaths = Array.isArray(meta.protected_paths) && meta.protected_paths.length > 0 ? meta.protected_paths.map((path) => path?.trim()).filter((path): path is string => Boolean(path)) : null; return { enabled: true, outpostDomain, outpostUpstream, authEndpoint, copyHeaders, trustedProxies, setOutpostHostHeader, protectedPaths }; } const VALID_LB_POLICIES = ["random", "round_robin", "least_conn", "ip_hash", "first", "header", "cookie", "uri_hash"]; function parseLoadBalancerConfig(meta: LoadBalancerMeta | undefined | null): LoadBalancerRouteConfig | null { if (!meta || !meta.enabled) { return null; } const policy = meta.policy && VALID_LB_POLICIES.includes(meta.policy) ? meta.policy : "random"; const policyHeaderField = typeof meta.policy_header_field === "string" ? meta.policy_header_field.trim() || null : null; const policyCookieName = typeof meta.policy_cookie_name === "string" ? meta.policy_cookie_name.trim() || null : null; const policyCookieSecret = typeof meta.policy_cookie_secret === "string" ? meta.policy_cookie_secret.trim() || null : null; const tryDuration = typeof meta.try_duration === "string" ? meta.try_duration.trim() || null : null; const tryInterval = typeof meta.try_interval === "string" ? meta.try_interval.trim() || null : null; const retries = typeof meta.retries === "number" && Number.isFinite(meta.retries) && meta.retries >= 0 ? meta.retries : null; let activeHealthCheck: LoadBalancerRouteConfig["activeHealthCheck"] = null; if (meta.active_health_check && meta.active_health_check.enabled) { activeHealthCheck = { enabled: true, uri: typeof meta.active_health_check.uri === "string" ? meta.active_health_check.uri.trim() || null : null, port: typeof meta.active_health_check.port === "number" && Number.isFinite(meta.active_health_check.port) && meta.active_health_check.port > 0 ? meta.active_health_check.port : null, interval: typeof meta.active_health_check.interval === "string" ? meta.active_health_check.interval.trim() || null : null, timeout: typeof meta.active_health_check.timeout === "string" ? meta.active_health_check.timeout.trim() || null : null, status: typeof meta.active_health_check.status === "number" && Number.isFinite(meta.active_health_check.status) && meta.active_health_check.status >= 100 ? meta.active_health_check.status : null, body: typeof meta.active_health_check.body === "string" ? meta.active_health_check.body.trim() || null : null }; } let passiveHealthCheck: LoadBalancerRouteConfig["passiveHealthCheck"] = null; if (meta.passive_health_check && meta.passive_health_check.enabled) { const unhealthyStatus = Array.isArray(meta.passive_health_check.unhealthy_status) ? meta.passive_health_check.unhealthy_status.filter((s): s is number => typeof s === "number" && Number.isFinite(s) && s >= 100) : null; passiveHealthCheck = { enabled: true, failDuration: typeof meta.passive_health_check.fail_duration === "string" ? meta.passive_health_check.fail_duration.trim() || null : null, maxFails: typeof meta.passive_health_check.max_fails === "number" && Number.isFinite(meta.passive_health_check.max_fails) && meta.passive_health_check.max_fails >= 0 ? meta.passive_health_check.max_fails : null, unhealthyStatus: unhealthyStatus && unhealthyStatus.length > 0 ? unhealthyStatus : null, unhealthyLatency: typeof meta.passive_health_check.unhealthy_latency === "string" ? meta.passive_health_check.unhealthy_latency.trim() || null : null }; } return { enabled: true, policy, policyHeaderField, policyCookieName, policyCookieSecret, tryDuration, tryInterval, retries, activeHealthCheck, passiveHealthCheck }; } function buildLoadBalancingConfig(config: LoadBalancerRouteConfig): Record | null { const loadBalancing: Record = {}; // Build selection policy const selectionPolicy: Record = { policy: config.policy }; if (config.policy === "header" && config.policyHeaderField) { selectionPolicy.policy = "header"; selectionPolicy.field = config.policyHeaderField; } else if (config.policy === "cookie" && config.policyCookieName) { selectionPolicy.policy = "cookie"; selectionPolicy.name = config.policyCookieName; if (config.policyCookieSecret) { selectionPolicy.secret = config.policyCookieSecret; } } loadBalancing.selection_policy = selectionPolicy; // Add retry settings if (config.tryDuration) { loadBalancing.try_duration = config.tryDuration; } if (config.tryInterval) { loadBalancing.try_interval = config.tryInterval; } if (config.retries !== null) { loadBalancing.retries = config.retries; } return Object.keys(loadBalancing).length > 0 ? loadBalancing : null; } type DnsResolverRouteConfig = { enabled: boolean; resolvers: string[]; fallbacks: string[] | null; timeout: string | null; }; function buildHealthChecksConfig(config: LoadBalancerRouteConfig): Record | null { const healthChecks: Record = {}; // Active health checks if (config.activeHealthCheck && config.activeHealthCheck.enabled) { const active: Record = {}; if (config.activeHealthCheck.uri) { active.uri = config.activeHealthCheck.uri; } if (config.activeHealthCheck.port !== null) { active.port = config.activeHealthCheck.port; } if (config.activeHealthCheck.interval) { active.interval = config.activeHealthCheck.interval; } if (config.activeHealthCheck.timeout) { active.timeout = config.activeHealthCheck.timeout; } if (config.activeHealthCheck.status !== null) { active.expect_status = config.activeHealthCheck.status; } if (config.activeHealthCheck.body) { active.expect_body = config.activeHealthCheck.body; } if (Object.keys(active).length > 0) { healthChecks.active = active; } } // Passive health checks if (config.passiveHealthCheck && config.passiveHealthCheck.enabled) { const passive: Record = {}; if (config.passiveHealthCheck.failDuration) { passive.fail_duration = config.passiveHealthCheck.failDuration; } if (config.passiveHealthCheck.maxFails !== null) { passive.max_fails = config.passiveHealthCheck.maxFails; } if (config.passiveHealthCheck.unhealthyStatus && config.passiveHealthCheck.unhealthyStatus.length > 0) { passive.unhealthy_status = config.passiveHealthCheck.unhealthyStatus; } if (config.passiveHealthCheck.unhealthyLatency) { passive.unhealthy_latency = config.passiveHealthCheck.unhealthyLatency; } if (Object.keys(passive).length > 0) { healthChecks.passive = passive; } } return Object.keys(healthChecks).length > 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 buildResolverConfig(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)); } return { addresses }; }