diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index d852c2c1..4057fe34 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { requireAdmin } from "@/src/lib/auth"; import { actionError, actionSuccess, INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions"; -import { createProxyHost, deleteProxyHost, updateProxyHost, type ProxyHostAuthentikInput } from "@/src/lib/models/proxy-hosts"; +import { createProxyHost, deleteProxyHost, updateProxyHost, type ProxyHostAuthentikInput, type LoadBalancerInput, type LoadBalancingPolicy } from "@/src/lib/models/proxy-hosts"; import { getCertificate } from "@/src/lib/models/certificates"; import { getCloudflareSettings } from "@/src/lib/settings"; @@ -143,6 +143,133 @@ function parseAuthentikConfig(formData: FormData): ProxyHostAuthentikInput | und return Object.keys(result).length > 0 ? result : undefined; } +function parseOptionalNumber(value: FormDataEntryValue | null): number | null { + if (!value || typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (trimmed === "") { + return null; + } + const num = Number(trimmed); + if (!Number.isFinite(num)) { + return null; + } + return num; +} + +const VALID_LB_POLICIES: LoadBalancingPolicy[] = ["random", "round_robin", "least_conn", "ip_hash", "first", "header", "cookie", "uri_hash"]; + +function parseLoadBalancerConfig(formData: FormData): LoadBalancerInput | undefined { + if (!formData.has("lb_present")) { + return undefined; + } + + const enabledIndicator = formData.has("lb_enabled_present"); + const enabledValue = enabledIndicator + ? formData.has("lb_enabled") + ? parseCheckbox(formData.get("lb_enabled")) + : false + : undefined; + + const policyRaw = parseOptionalText(formData.get("lb_policy")); + const policy = policyRaw && VALID_LB_POLICIES.includes(policyRaw as LoadBalancingPolicy) + ? (policyRaw as LoadBalancingPolicy) + : undefined; + + const policyHeaderField = parseOptionalText(formData.get("lb_policy_header_field")); + const policyCookieName = parseOptionalText(formData.get("lb_policy_cookie_name")); + const policyCookieSecret = parseOptionalText(formData.get("lb_policy_cookie_secret")); + const tryDuration = parseOptionalText(formData.get("lb_try_duration")); + const tryInterval = parseOptionalText(formData.get("lb_try_interval")); + const retries = parseOptionalNumber(formData.get("lb_retries")); + + // Active health check + const activeHealthEnabled = formData.has("lb_active_health_enabled_present") + ? formData.has("lb_active_health_enabled") + ? parseCheckbox(formData.get("lb_active_health_enabled")) + : false + : undefined; + + let activeHealthCheck: LoadBalancerInput["activeHealthCheck"] = undefined; + if (activeHealthEnabled !== undefined || formData.has("lb_active_health_uri")) { + activeHealthCheck = { + enabled: activeHealthEnabled, + uri: parseOptionalText(formData.get("lb_active_health_uri")), + port: parseOptionalNumber(formData.get("lb_active_health_port")), + interval: parseOptionalText(formData.get("lb_active_health_interval")), + timeout: parseOptionalText(formData.get("lb_active_health_timeout")), + status: parseOptionalNumber(formData.get("lb_active_health_status")), + body: parseOptionalText(formData.get("lb_active_health_body")) + }; + } + + // Passive health check + const passiveHealthEnabled = formData.has("lb_passive_health_enabled_present") + ? formData.has("lb_passive_health_enabled") + ? parseCheckbox(formData.get("lb_passive_health_enabled")) + : false + : undefined; + + let passiveHealthCheck: LoadBalancerInput["passiveHealthCheck"] = undefined; + if (passiveHealthEnabled !== undefined || formData.has("lb_passive_health_fail_duration")) { + // Parse unhealthy status codes from comma-separated input + const unhealthyStatusRaw = parseOptionalText(formData.get("lb_passive_health_unhealthy_status")); + let unhealthyStatus: number[] | null = null; + if (unhealthyStatusRaw) { + unhealthyStatus = unhealthyStatusRaw + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n) && n >= 100); + if (unhealthyStatus.length === 0) { + unhealthyStatus = null; + } + } + + passiveHealthCheck = { + enabled: passiveHealthEnabled, + failDuration: parseOptionalText(formData.get("lb_passive_health_fail_duration")), + maxFails: parseOptionalNumber(formData.get("lb_passive_health_max_fails")), + unhealthyStatus, + unhealthyLatency: parseOptionalText(formData.get("lb_passive_health_unhealthy_latency")) + }; + } + + const result: LoadBalancerInput = {}; + if (enabledValue !== undefined) { + result.enabled = enabledValue; + } + if (policy !== undefined) { + result.policy = policy; + } + if (policyHeaderField !== null) { + result.policyHeaderField = policyHeaderField; + } + if (policyCookieName !== null) { + result.policyCookieName = policyCookieName; + } + if (policyCookieSecret !== null) { + result.policyCookieSecret = policyCookieSecret; + } + if (tryDuration !== null) { + result.tryDuration = tryDuration; + } + if (tryInterval !== null) { + result.tryInterval = tryInterval; + } + if (retries !== null) { + result.retries = retries; + } + if (activeHealthCheck !== undefined) { + result.activeHealthCheck = activeHealthCheck; + } + if (passiveHealthCheck !== undefined) { + result.passiveHealthCheck = passiveHealthCheck; + } + + return Object.keys(result).length > 0 ? result : undefined; +} + export async function createProxyHostAction( _prevState: ActionState = INITIAL_ACTION_STATE, formData: FormData @@ -177,7 +304,8 @@ export async function createProxyHostAction( enabled: parseCheckbox(formData.get("enabled")), custom_pre_handlers_json: parseOptionalText(formData.get("custom_pre_handlers_json")), custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")), - authentik: parseAuthentikConfig(formData) + authentik: parseAuthentikConfig(formData), + load_balancer: parseLoadBalancerConfig(formData) }, userId ); @@ -244,7 +372,8 @@ export async function updateProxyHostAction( custom_reverse_proxy_json: formData.has("custom_reverse_proxy_json") ? parseOptionalText(formData.get("custom_reverse_proxy_json")) : undefined, - authentik: parseAuthentikConfig(formData) + authentik: parseAuthentikConfig(formData), + load_balancer: parseLoadBalancerConfig(formData) }, userId ); diff --git a/src/components/proxy-hosts/HostDialogs.tsx b/src/components/proxy-hosts/HostDialogs.tsx index 88c5ccec..f6c3259d 100644 --- a/src/components/proxy-hosts/HostDialogs.tsx +++ b/src/components/proxy-hosts/HostDialogs.tsx @@ -14,6 +14,7 @@ import { ProxyHost } from "@/src/lib/models/proxy-hosts"; import { AuthentikSettings } from "@/src/lib/settings"; import { AppDialog } from "@/src/components/ui/AppDialog"; import { AuthentikFields } from "./AuthentikFields"; +import { LoadBalancerFields } from "./LoadBalancerFields"; import { SettingsToggles } from "./SettingsToggles"; import { UpstreamInput } from "./UpstreamInput"; @@ -120,6 +121,7 @@ export function CreateHostDialog({ fullWidth /> + ); @@ -214,6 +216,7 @@ export function EditHostDialog({ fullWidth /> + ); diff --git a/src/components/proxy-hosts/LoadBalancerFields.tsx b/src/components/proxy-hosts/LoadBalancerFields.tsx new file mode 100644 index 00000000..979793bb --- /dev/null +++ b/src/components/proxy-hosts/LoadBalancerFields.tsx @@ -0,0 +1,339 @@ + +import { Box, Collapse, FormControlLabel, Stack, Switch, TextField, Typography, MenuItem } from "@mui/material"; +import { useState } from "react"; +import { ProxyHost, LoadBalancingPolicy } from "@/src/lib/models/proxy-hosts"; + +const LOAD_BALANCING_POLICIES = [ + { value: "random", label: "Random", description: "Random selection (default)" }, + { value: "round_robin", label: "Round Robin", description: "Sequential distribution" }, + { value: "least_conn", label: "Least Connections", description: "Fewest active connections" }, + { value: "ip_hash", label: "IP Hash", description: "Client IP-based sticky sessions" }, + { value: "first", label: "First Available", description: "First available upstream" }, + { value: "header", label: "Header Hash", description: "Hash based on request header" }, + { value: "cookie", label: "Cookie", description: "Cookie-based sticky sessions" }, + { value: "uri_hash", label: "URI Hash", description: "URI path-based distribution" } +]; + +export function LoadBalancerFields({ + loadBalancer +}: { + loadBalancer?: ProxyHost["load_balancer"] | null; +}) { + const initial = loadBalancer ?? null; + const [enabled, setEnabled] = useState(initial?.enabled ?? false); + const [policy, setPolicy] = useState(initial?.policy ?? "random"); + const [activeHealthEnabled, setActiveHealthEnabled] = useState(initial?.activeHealthCheck?.enabled ?? false); + const [passiveHealthEnabled, setPassiveHealthEnabled] = useState(initial?.passiveHealthCheck?.enabled ?? false); + + const showHeaderField = policy === "header"; + const showCookieFields = policy === "cookie"; + + return ( + + + + + + + + Load Balancer + + + Configure load balancing and health checks for multiple upstreams + + + setEnabled(checked)} + /> + + + + + {/* Policy Selection */} + + + Selection Policy + + setPolicy(e.target.value as LoadBalancingPolicy)} + fullWidth + size="small" + > + {LOAD_BALANCING_POLICIES.map((p) => ( + + {p.label} - {p.description} + + ))} + + + + {/* Header-based policy fields */} + + + + + {/* Cookie-based policy fields */} + + + + + + + + {/* Retry Settings */} + + + Retry Settings + + + + + + + + + {/* Active Health Checks */} + + + + setActiveHealthEnabled(checked)} + size="small" + /> + } + label={ + + Active Health Checks + + Periodically probe upstreams to check health + + + } + /> + + + + + + + + + + + + + + + + + + + + + {/* Passive Health Checks */} + + + + setPassiveHealthEnabled(checked)} + size="small" + /> + } + label={ + + Passive Health Checks + + Mark upstreams unhealthy based on response failures + + + } + /> + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/lib/caddy.ts b/src/lib/caddy.ts index 2b2cdadf..09eb325f 100644 --- a/src/lib/caddy.ts +++ b/src/lib/caddy.ts @@ -53,6 +53,7 @@ type ProxyHostMeta = { custom_reverse_proxy_json?: string; custom_pre_handlers_json?: string; authentik?: ProxyHostAuthentikMeta; + load_balancer?: LoadBalancerMeta; }; type ProxyHostAuthentikMeta = { @@ -77,6 +78,64 @@ type AuthentikRouteConfig = { 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 RedirectHostRow = { id: number; name: string; @@ -412,6 +471,19 @@ function buildProxyRoutes( }; } + // Configure load balancing and health checks + const lbConfig = parseLoadBalancerConfig(meta.load_balancer); + if (lbConfig) { + const loadBalancing = buildLoadBalancingConfig(lbConfig); + if (loadBalancing) { + reverseProxyHandler.load_balancing = loadBalancing; + } + const healthChecks = buildHealthChecksConfig(lbConfig); + if (healthChecks) { + reverseProxyHandler.health_checks = healthChecks; + } + } + const customReverseProxy = parseOptionalJson(meta.custom_reverse_proxy_json); if (customReverseProxy) { if (isPlainObject(customReverseProxy)) { @@ -1103,3 +1175,155 @@ function parseAuthentikConfig(meta: ProxyHostAuthentikMeta | undefined | null): 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; +} + +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; +} diff --git a/src/lib/models/proxy-hosts.ts b/src/lib/models/proxy-hosts.ts index c213176f..f455be80 100644 --- a/src/lib/models/proxy-hosts.ts +++ b/src/lib/models/proxy-hosts.ts @@ -21,6 +21,98 @@ const DEFAULT_AUTHENTIK_HEADERS = [ const DEFAULT_AUTHENTIK_TRUSTED_PROXIES = ["private_ranges"]; +// Load Balancer Types +export type LoadBalancingPolicy = "random" | "round_robin" | "least_conn" | "ip_hash" | "first" | "header" | "cookie" | "uri_hash"; + +export type LoadBalancerActiveHealthCheck = { + enabled: boolean; + uri: string | null; + port: number | null; + interval: string | null; + timeout: string | null; + status: number | null; + body: string | null; +}; + +export type LoadBalancerPassiveHealthCheck = { + enabled: boolean; + failDuration: string | null; + maxFails: number | null; + unhealthyStatus: number[] | null; + unhealthyLatency: string | null; +}; + +export type LoadBalancerConfig = { + enabled: boolean; + policy: LoadBalancingPolicy; + policyHeaderField: string | null; + policyCookieName: string | null; + policyCookieSecret: string | null; + tryDuration: string | null; + tryInterval: string | null; + retries: number | null; + activeHealthCheck: LoadBalancerActiveHealthCheck | null; + passiveHealthCheck: LoadBalancerPassiveHealthCheck | null; +}; + +export type LoadBalancerInput = { + enabled?: boolean; + policy?: LoadBalancingPolicy; + 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 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; +}; + export type ProxyHostAuthentikConfig = { enabled: boolean; outpostDomain: string | null; @@ -58,6 +150,7 @@ type ProxyHostMeta = { custom_reverse_proxy_json?: string; custom_pre_handlers_json?: string; authentik?: ProxyHostAuthentikMeta; + load_balancer?: LoadBalancerMeta; }; export type ProxyHost = { @@ -79,6 +172,7 @@ export type ProxyHost = { custom_reverse_proxy_json: string | null; custom_pre_handlers_json: string | null; authentik: ProxyHostAuthentikConfig | null; + load_balancer: LoadBalancerConfig | null; }; export type ProxyHostInput = { @@ -97,6 +191,7 @@ export type ProxyHostInput = { custom_reverse_proxy_json?: string | null; custom_pre_handlers_json?: string | null; authentik?: ProxyHostAuthentikInput | null; + load_balancer?: LoadBalancerInput | null; }; type ProxyHostRow = typeof proxyHosts.$inferSelect; @@ -163,6 +258,114 @@ function sanitizeAuthentikMeta(meta: ProxyHostAuthentikMeta | undefined): ProxyH return Object.keys(normalized).length > 0 ? normalized : undefined; } +const VALID_LB_POLICIES: LoadBalancingPolicy[] = ["random", "round_robin", "least_conn", "ip_hash", "first", "header", "cookie", "uri_hash"]; + +function sanitizeLoadBalancerMeta(meta: LoadBalancerMeta | undefined): LoadBalancerMeta | undefined { + if (!meta) { + return undefined; + } + + const normalized: LoadBalancerMeta = {}; + + if (meta.enabled !== undefined) { + normalized.enabled = Boolean(meta.enabled); + } + + if (meta.policy && VALID_LB_POLICIES.includes(meta.policy as LoadBalancingPolicy)) { + normalized.policy = meta.policy; + } + + const headerField = normalizeMetaValue(meta.policy_header_field ?? null); + if (headerField) { + normalized.policy_header_field = headerField; + } + + const cookieName = normalizeMetaValue(meta.policy_cookie_name ?? null); + if (cookieName) { + normalized.policy_cookie_name = cookieName; + } + + const cookieSecret = normalizeMetaValue(meta.policy_cookie_secret ?? null); + if (cookieSecret) { + normalized.policy_cookie_secret = cookieSecret; + } + + const tryDuration = normalizeMetaValue(meta.try_duration ?? null); + if (tryDuration) { + normalized.try_duration = tryDuration; + } + + const tryInterval = normalizeMetaValue(meta.try_interval ?? null); + if (tryInterval) { + normalized.try_interval = tryInterval; + } + + if (typeof meta.retries === "number" && Number.isFinite(meta.retries) && meta.retries >= 0) { + normalized.retries = meta.retries; + } + + if (meta.active_health_check) { + const ahc: LoadBalancerActiveHealthCheckMeta = {}; + if (meta.active_health_check.enabled !== undefined) { + ahc.enabled = Boolean(meta.active_health_check.enabled); + } + const uri = normalizeMetaValue(meta.active_health_check.uri ?? null); + if (uri) { + ahc.uri = uri; + } + if (typeof meta.active_health_check.port === "number" && Number.isFinite(meta.active_health_check.port) && meta.active_health_check.port > 0) { + ahc.port = meta.active_health_check.port; + } + const interval = normalizeMetaValue(meta.active_health_check.interval ?? null); + if (interval) { + ahc.interval = interval; + } + const timeout = normalizeMetaValue(meta.active_health_check.timeout ?? null); + if (timeout) { + ahc.timeout = timeout; + } + if (typeof meta.active_health_check.status === "number" && Number.isFinite(meta.active_health_check.status) && meta.active_health_check.status >= 100) { + ahc.status = meta.active_health_check.status; + } + const body = normalizeMetaValue(meta.active_health_check.body ?? null); + if (body) { + ahc.body = body; + } + if (Object.keys(ahc).length > 0) { + normalized.active_health_check = ahc; + } + } + + if (meta.passive_health_check) { + const phc: LoadBalancerPassiveHealthCheckMeta = {}; + if (meta.passive_health_check.enabled !== undefined) { + phc.enabled = Boolean(meta.passive_health_check.enabled); + } + const failDuration = normalizeMetaValue(meta.passive_health_check.fail_duration ?? null); + if (failDuration) { + phc.fail_duration = failDuration; + } + if (typeof meta.passive_health_check.max_fails === "number" && Number.isFinite(meta.passive_health_check.max_fails) && meta.passive_health_check.max_fails >= 0) { + phc.max_fails = meta.passive_health_check.max_fails; + } + if (Array.isArray(meta.passive_health_check.unhealthy_status)) { + const statuses = meta.passive_health_check.unhealthy_status.filter((s): s is number => typeof s === "number" && Number.isFinite(s) && s >= 100); + if (statuses.length > 0) { + phc.unhealthy_status = statuses; + } + } + const unhealthyLatency = normalizeMetaValue(meta.passive_health_check.unhealthy_latency ?? null); + if (unhealthyLatency) { + phc.unhealthy_latency = unhealthyLatency; + } + if (Object.keys(phc).length > 0) { + normalized.passive_health_check = phc; + } + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + function serializeMeta(meta: ProxyHostMeta | null | undefined) { if (!meta) { return null; @@ -183,6 +386,11 @@ function serializeMeta(meta: ProxyHostMeta | null | undefined) { normalized.authentik = authentik; } + const loadBalancer = sanitizeLoadBalancerMeta(meta.load_balancer); + if (loadBalancer) { + normalized.load_balancer = loadBalancer; + } + return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null; } @@ -195,7 +403,8 @@ function parseMeta(value: string | null): ProxyHostMeta { return { custom_reverse_proxy_json: normalizeMetaValue(parsed.custom_reverse_proxy_json ?? null) ?? undefined, custom_pre_handlers_json: normalizeMetaValue(parsed.custom_pre_handlers_json ?? null) ?? undefined, - authentik: sanitizeAuthentikMeta(parsed.authentik) + authentik: sanitizeAuthentikMeta(parsed.authentik), + load_balancer: sanitizeLoadBalancerMeta(parsed.load_balancer) }; } catch (error) { console.warn("Failed to parse proxy host meta", error); @@ -291,6 +500,204 @@ function normalizeAuthentikInput( return Object.keys(next).length > 0 ? next : undefined; } +function normalizeLoadBalancerInput( + input: LoadBalancerInput | null | undefined, + existing: LoadBalancerMeta | undefined +): LoadBalancerMeta | undefined { + if (input === undefined) { + return existing; + } + if (input === null) { + return undefined; + } + + const next: LoadBalancerMeta = { ...(existing ?? {}) }; + + if (input.enabled !== undefined) { + next.enabled = Boolean(input.enabled); + } + + if (input.policy !== undefined) { + if (input.policy && VALID_LB_POLICIES.includes(input.policy)) { + next.policy = input.policy; + } else { + delete next.policy; + } + } + + if (input.policyHeaderField !== undefined) { + const val = normalizeMetaValue(input.policyHeaderField ?? null); + if (val) { + next.policy_header_field = val; + } else { + delete next.policy_header_field; + } + } + + if (input.policyCookieName !== undefined) { + const val = normalizeMetaValue(input.policyCookieName ?? null); + if (val) { + next.policy_cookie_name = val; + } else { + delete next.policy_cookie_name; + } + } + + if (input.policyCookieSecret !== undefined) { + const val = normalizeMetaValue(input.policyCookieSecret ?? null); + if (val) { + next.policy_cookie_secret = val; + } else { + delete next.policy_cookie_secret; + } + } + + if (input.tryDuration !== undefined) { + const val = normalizeMetaValue(input.tryDuration ?? null); + if (val) { + next.try_duration = val; + } else { + delete next.try_duration; + } + } + + if (input.tryInterval !== undefined) { + const val = normalizeMetaValue(input.tryInterval ?? null); + if (val) { + next.try_interval = val; + } else { + delete next.try_interval; + } + } + + if (input.retries !== undefined) { + if (typeof input.retries === "number" && Number.isFinite(input.retries) && input.retries >= 0) { + next.retries = input.retries; + } else { + delete next.retries; + } + } + + if (input.activeHealthCheck !== undefined) { + if (input.activeHealthCheck === null) { + delete next.active_health_check; + } else { + const ahc: LoadBalancerActiveHealthCheckMeta = { ...(existing?.active_health_check ?? {}) }; + + if (input.activeHealthCheck.enabled !== undefined) { + ahc.enabled = Boolean(input.activeHealthCheck.enabled); + } + if (input.activeHealthCheck.uri !== undefined) { + const val = normalizeMetaValue(input.activeHealthCheck.uri ?? null); + if (val) { + ahc.uri = val; + } else { + delete ahc.uri; + } + } + if (input.activeHealthCheck.port !== undefined) { + if (typeof input.activeHealthCheck.port === "number" && Number.isFinite(input.activeHealthCheck.port) && input.activeHealthCheck.port > 0) { + ahc.port = input.activeHealthCheck.port; + } else { + delete ahc.port; + } + } + if (input.activeHealthCheck.interval !== undefined) { + const val = normalizeMetaValue(input.activeHealthCheck.interval ?? null); + if (val) { + ahc.interval = val; + } else { + delete ahc.interval; + } + } + if (input.activeHealthCheck.timeout !== undefined) { + const val = normalizeMetaValue(input.activeHealthCheck.timeout ?? null); + if (val) { + ahc.timeout = val; + } else { + delete ahc.timeout; + } + } + if (input.activeHealthCheck.status !== undefined) { + if (typeof input.activeHealthCheck.status === "number" && Number.isFinite(input.activeHealthCheck.status) && input.activeHealthCheck.status >= 100) { + ahc.status = input.activeHealthCheck.status; + } else { + delete ahc.status; + } + } + if (input.activeHealthCheck.body !== undefined) { + const val = normalizeMetaValue(input.activeHealthCheck.body ?? null); + if (val) { + ahc.body = val; + } else { + delete ahc.body; + } + } + + if (Object.keys(ahc).length > 0) { + next.active_health_check = ahc; + } else { + delete next.active_health_check; + } + } + } + + if (input.passiveHealthCheck !== undefined) { + if (input.passiveHealthCheck === null) { + delete next.passive_health_check; + } else { + const phc: LoadBalancerPassiveHealthCheckMeta = { ...(existing?.passive_health_check ?? {}) }; + + if (input.passiveHealthCheck.enabled !== undefined) { + phc.enabled = Boolean(input.passiveHealthCheck.enabled); + } + if (input.passiveHealthCheck.failDuration !== undefined) { + const val = normalizeMetaValue(input.passiveHealthCheck.failDuration ?? null); + if (val) { + phc.fail_duration = val; + } else { + delete phc.fail_duration; + } + } + if (input.passiveHealthCheck.maxFails !== undefined) { + if (typeof input.passiveHealthCheck.maxFails === "number" && Number.isFinite(input.passiveHealthCheck.maxFails) && input.passiveHealthCheck.maxFails >= 0) { + phc.max_fails = input.passiveHealthCheck.maxFails; + } else { + delete phc.max_fails; + } + } + if (input.passiveHealthCheck.unhealthyStatus !== undefined) { + if (Array.isArray(input.passiveHealthCheck.unhealthyStatus)) { + const statuses = input.passiveHealthCheck.unhealthyStatus.filter((s): s is number => typeof s === "number" && Number.isFinite(s) && s >= 100); + if (statuses.length > 0) { + phc.unhealthy_status = statuses; + } else { + delete phc.unhealthy_status; + } + } else { + delete phc.unhealthy_status; + } + } + if (input.passiveHealthCheck.unhealthyLatency !== undefined) { + const val = normalizeMetaValue(input.passiveHealthCheck.unhealthyLatency ?? null); + if (val) { + phc.unhealthy_latency = val; + } else { + delete phc.unhealthy_latency; + } + } + + if (Object.keys(phc).length > 0) { + next.passive_health_check = phc; + } else { + delete next.passive_health_check; + } + } + } + + return Object.keys(next).length > 0 ? next : undefined; +} + function buildMeta(existing: ProxyHostMeta, input: Partial): string | null { const next: ProxyHostMeta = { ...existing }; @@ -321,6 +728,15 @@ function buildMeta(existing: ProxyHostMeta, input: Partial): str } } + if (input.load_balancer !== undefined) { + const loadBalancer = normalizeLoadBalancerInput(input.load_balancer, existing.load_balancer); + if (loadBalancer) { + next.load_balancer = loadBalancer; + } else { + delete next.load_balancer; + } + } + return serializeMeta(next); } @@ -389,6 +805,149 @@ function dehydrateAuthentik(config: ProxyHostAuthentikConfig | null): ProxyHostA return meta; } +function hydrateLoadBalancer(meta: LoadBalancerMeta | undefined): LoadBalancerConfig | null { + if (!meta) { + return null; + } + + const enabled = Boolean(meta.enabled); + const policy: LoadBalancingPolicy = (meta.policy && VALID_LB_POLICIES.includes(meta.policy as LoadBalancingPolicy)) + ? (meta.policy as LoadBalancingPolicy) + : "random"; + + const policyHeaderField = normalizeMetaValue(meta.policy_header_field ?? null); + const policyCookieName = normalizeMetaValue(meta.policy_cookie_name ?? null); + const policyCookieSecret = normalizeMetaValue(meta.policy_cookie_secret ?? null); + const tryDuration = normalizeMetaValue(meta.try_duration ?? null); + const tryInterval = normalizeMetaValue(meta.try_interval ?? null); + const retries = typeof meta.retries === "number" && Number.isFinite(meta.retries) && meta.retries >= 0 ? meta.retries : null; + + let activeHealthCheck: LoadBalancerActiveHealthCheck | null = null; + if (meta.active_health_check) { + activeHealthCheck = { + enabled: Boolean(meta.active_health_check.enabled), + uri: normalizeMetaValue(meta.active_health_check.uri ?? 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: normalizeMetaValue(meta.active_health_check.interval ?? null), + timeout: normalizeMetaValue(meta.active_health_check.timeout ?? 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: normalizeMetaValue(meta.active_health_check.body ?? null) + }; + } + + let passiveHealthCheck: LoadBalancerPassiveHealthCheck | null = null; + if (meta.passive_health_check) { + 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: Boolean(meta.passive_health_check.enabled), + failDuration: normalizeMetaValue(meta.passive_health_check.fail_duration ?? 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: normalizeMetaValue(meta.passive_health_check.unhealthy_latency ?? null) + }; + } + + return { + enabled, + policy, + policyHeaderField, + policyCookieName, + policyCookieSecret, + tryDuration, + tryInterval, + retries, + activeHealthCheck, + passiveHealthCheck + }; +} + +function dehydrateLoadBalancer(config: LoadBalancerConfig | null): LoadBalancerMeta | undefined { + if (!config) { + return undefined; + } + + const meta: LoadBalancerMeta = { + enabled: config.enabled + }; + + if (config.policy) { + meta.policy = config.policy; + } + if (config.policyHeaderField) { + meta.policy_header_field = config.policyHeaderField; + } + if (config.policyCookieName) { + meta.policy_cookie_name = config.policyCookieName; + } + if (config.policyCookieSecret) { + meta.policy_cookie_secret = config.policyCookieSecret; + } + if (config.tryDuration) { + meta.try_duration = config.tryDuration; + } + if (config.tryInterval) { + meta.try_interval = config.tryInterval; + } + if (config.retries !== null) { + meta.retries = config.retries; + } + + if (config.activeHealthCheck) { + const ahc: LoadBalancerActiveHealthCheckMeta = { + enabled: config.activeHealthCheck.enabled + }; + if (config.activeHealthCheck.uri) { + ahc.uri = config.activeHealthCheck.uri; + } + if (config.activeHealthCheck.port !== null) { + ahc.port = config.activeHealthCheck.port; + } + if (config.activeHealthCheck.interval) { + ahc.interval = config.activeHealthCheck.interval; + } + if (config.activeHealthCheck.timeout) { + ahc.timeout = config.activeHealthCheck.timeout; + } + if (config.activeHealthCheck.status !== null) { + ahc.status = config.activeHealthCheck.status; + } + if (config.activeHealthCheck.body) { + ahc.body = config.activeHealthCheck.body; + } + meta.active_health_check = ahc; + } + + if (config.passiveHealthCheck) { + const phc: LoadBalancerPassiveHealthCheckMeta = { + enabled: config.passiveHealthCheck.enabled + }; + if (config.passiveHealthCheck.failDuration) { + phc.fail_duration = config.passiveHealthCheck.failDuration; + } + if (config.passiveHealthCheck.maxFails !== null) { + phc.max_fails = config.passiveHealthCheck.maxFails; + } + if (config.passiveHealthCheck.unhealthyStatus && config.passiveHealthCheck.unhealthyStatus.length > 0) { + phc.unhealthy_status = [...config.passiveHealthCheck.unhealthyStatus]; + } + if (config.passiveHealthCheck.unhealthyLatency) { + phc.unhealthy_latency = config.passiveHealthCheck.unhealthyLatency; + } + meta.passive_health_check = phc; + } + + return meta; +} + function parseProxyHost(row: ProxyHostRow): ProxyHost { const meta = parseMeta(row.meta ?? null); return { @@ -409,7 +968,8 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost { updated_at: toIso(row.updatedAt)!, custom_reverse_proxy_json: meta.custom_reverse_proxy_json ?? null, custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null, - authentik: hydrateAuthentik(meta.authentik) + authentik: hydrateAuthentik(meta.authentik), + load_balancer: hydrateLoadBalancer(meta.load_balancer) }; } @@ -485,7 +1045,8 @@ export async function updateProxyHost(id: number, input: Partial const existingMeta: ProxyHostMeta = { custom_reverse_proxy_json: existing.custom_reverse_proxy_json ?? undefined, custom_pre_handlers_json: existing.custom_pre_handlers_json ?? undefined, - authentik: dehydrateAuthentik(existing.authentik) + authentik: dehydrateAuthentik(existing.authentik), + load_balancer: dehydrateLoadBalancer(existing.load_balancer) }; const meta = buildMeta(existingMeta, input);