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) => (
+
+ ))}
+
+
+
+ {/* 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);