added load balancing settings
This commit is contained in:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
<AuthentikFields defaults={authentikDefaults} authentik={initialData?.authentik} />
|
||||
<LoadBalancerFields loadBalancer={initialData?.load_balancer} />
|
||||
</Stack>
|
||||
</AppDialog>
|
||||
);
|
||||
@@ -214,6 +216,7 @@ export function EditHostDialog({
|
||||
fullWidth
|
||||
/>
|
||||
<AuthentikFields authentik={host.authentik} />
|
||||
<LoadBalancerFields loadBalancer={host.load_balancer} />
|
||||
</Stack>
|
||||
</AppDialog>
|
||||
);
|
||||
|
||||
339
src/components/proxy-hosts/LoadBalancerFields.tsx
Normal file
339
src/components/proxy-hosts/LoadBalancerFields.tsx
Normal file
@@ -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<LoadBalancingPolicy>(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 (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: "info.main",
|
||||
bgcolor: "rgba(2, 136, 209, 0.05)",
|
||||
p: 2.5
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="lb_present" value="1" />
|
||||
<input type="hidden" name="lb_enabled_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Load Balancer
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Configure load balancing and health checks for multiple upstreams
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
name="lb_enabled"
|
||||
checked={enabled}
|
||||
onChange={(_, checked) => setEnabled(checked)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={enabled} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2.5}>
|
||||
{/* Policy Selection */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Selection Policy
|
||||
</Typography>
|
||||
<TextField
|
||||
select
|
||||
name="lb_policy"
|
||||
label="Load Balancing Policy"
|
||||
value={policy}
|
||||
onChange={(e) => setPolicy(e.target.value as LoadBalancingPolicy)}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
{LOAD_BALANCING_POLICIES.map((p) => (
|
||||
<MenuItem key={p.value} value={p.value}>
|
||||
{p.label} - {p.description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Box>
|
||||
|
||||
{/* Header-based policy fields */}
|
||||
<Collapse in={showHeaderField} timeout="auto" unmountOnExit>
|
||||
<TextField
|
||||
name="lb_policy_header_field"
|
||||
label="Header Field Name"
|
||||
placeholder="X-Custom-Header"
|
||||
defaultValue={initial?.policyHeaderField ?? ""}
|
||||
helperText="The request header to hash for upstream selection"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Collapse>
|
||||
|
||||
{/* Cookie-based policy fields */}
|
||||
<Collapse in={showCookieFields} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="lb_policy_cookie_name"
|
||||
label="Cookie Name"
|
||||
placeholder="server_id"
|
||||
defaultValue={initial?.policyCookieName ?? ""}
|
||||
helperText="Name of the cookie for sticky sessions"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_policy_cookie_secret"
|
||||
label="Cookie Secret (Optional)"
|
||||
placeholder="your-secret-key"
|
||||
defaultValue={initial?.policyCookieSecret ?? ""}
|
||||
helperText="Secret key for HMAC cookie signing"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
|
||||
{/* Retry Settings */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Retry Settings
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_try_duration"
|
||||
label="Try Duration"
|
||||
placeholder="5s"
|
||||
defaultValue={initial?.tryDuration ?? ""}
|
||||
helperText="How long to try upstreams"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_try_interval"
|
||||
label="Try Interval"
|
||||
placeholder="250ms"
|
||||
defaultValue={initial?.tryInterval ?? ""}
|
||||
helperText="Wait between attempts"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_retries"
|
||||
label="Max Retries"
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
defaultValue={initial?.retries ?? ""}
|
||||
helperText="Maximum retry attempts"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Active Health Checks */}
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
p: 2
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="lb_active_health_enabled_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name="lb_active_health_enabled"
|
||||
checked={activeHealthEnabled}
|
||||
onChange={(_, checked) => setActiveHealthEnabled(checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Active Health Checks</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Periodically probe upstreams to check health
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<Collapse in={activeHealthEnabled} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_active_health_uri"
|
||||
label="Health Check URI"
|
||||
placeholder="/health"
|
||||
defaultValue={initial?.activeHealthCheck?.uri ?? ""}
|
||||
helperText="Path to probe for health"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_active_health_port"
|
||||
label="Health Check Port"
|
||||
type="number"
|
||||
inputProps={{ min: 1, max: 65535 }}
|
||||
defaultValue={initial?.activeHealthCheck?.port ?? ""}
|
||||
helperText="Override upstream port"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_active_health_interval"
|
||||
label="Check Interval"
|
||||
placeholder="30s"
|
||||
defaultValue={initial?.activeHealthCheck?.interval ?? ""}
|
||||
helperText="How often to check"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_active_health_timeout"
|
||||
label="Check Timeout"
|
||||
placeholder="5s"
|
||||
defaultValue={initial?.activeHealthCheck?.timeout ?? ""}
|
||||
helperText="Timeout for health probe"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_active_health_status"
|
||||
label="Expected Status Code"
|
||||
type="number"
|
||||
inputProps={{ min: 100, max: 599 }}
|
||||
defaultValue={initial?.activeHealthCheck?.status ?? ""}
|
||||
helperText="Expected HTTP status"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_active_health_body"
|
||||
label="Expected Body"
|
||||
placeholder="OK"
|
||||
defaultValue={initial?.activeHealthCheck?.body ?? ""}
|
||||
helperText="Expected response body"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Passive Health Checks */}
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
p: 2
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="lb_passive_health_enabled_present" value="1" />
|
||||
<Stack spacing={2}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name="lb_passive_health_enabled"
|
||||
checked={passiveHealthEnabled}
|
||||
onChange={(_, checked) => setPassiveHealthEnabled(checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Box>
|
||||
<Typography variant="subtitle2">Passive Health Checks</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Mark upstreams unhealthy based on response failures
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
|
||||
<Collapse in={passiveHealthEnabled} timeout="auto" unmountOnExit>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_passive_health_fail_duration"
|
||||
label="Fail Duration"
|
||||
placeholder="30s"
|
||||
defaultValue={initial?.passiveHealthCheck?.failDuration ?? ""}
|
||||
helperText="How long to remember failures"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_passive_health_max_fails"
|
||||
label="Max Failures"
|
||||
type="number"
|
||||
inputProps={{ min: 0 }}
|
||||
defaultValue={initial?.passiveHealthCheck?.maxFails ?? ""}
|
||||
helperText="Failures before marking unhealthy"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
<TextField
|
||||
name="lb_passive_health_unhealthy_status"
|
||||
label="Unhealthy Status Codes"
|
||||
placeholder="500, 502, 503"
|
||||
defaultValue={initial?.passiveHealthCheck?.unhealthyStatus?.join(", ") ?? ""}
|
||||
helperText="Comma-separated status codes"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
name="lb_passive_health_unhealthy_latency"
|
||||
label="Unhealthy Latency"
|
||||
placeholder="5s"
|
||||
defaultValue={initial?.passiveHealthCheck?.unhealthyLatency ?? ""}
|
||||
helperText="Latency threshold for unhealthy"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
224
src/lib/caddy.ts
224
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<string, unknown> | null {
|
||||
const loadBalancing: Record<string, unknown> = {};
|
||||
|
||||
// Build selection policy
|
||||
const selectionPolicy: Record<string, unknown> = { 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<string, unknown> | null {
|
||||
const healthChecks: Record<string, unknown> = {};
|
||||
|
||||
// Active health checks
|
||||
if (config.activeHealthCheck && config.activeHealthCheck.enabled) {
|
||||
const active: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<ProxyHostInput>): string | null {
|
||||
const next: ProxyHostMeta = { ...existing };
|
||||
|
||||
@@ -321,6 +728,15 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): 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<ProxyHostInput>
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user