mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-14 16:49:05 +02:00
Add support for configuring load balancing strategy
This commit is contained in:
@@ -793,6 +793,15 @@
|
|||||||
"targetsDescription": "Set up targets to route traffic to backend services",
|
"targetsDescription": "Set up targets to route traffic to backend services",
|
||||||
"targetStickySessions": "Enable Sticky Sessions",
|
"targetStickySessions": "Enable Sticky Sessions",
|
||||||
"targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.",
|
"targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.",
|
||||||
|
"loadBalancingStrategy": "Load Balancing Strategy",
|
||||||
|
"loadBalancingStrategy_wrr": "Round Robin",
|
||||||
|
"loadBalancingStrategy_wrr_description": "Distribute requests evenly across targets in rotation.",
|
||||||
|
"loadBalancingStrategy_p2c": "Power of Two Choices",
|
||||||
|
"loadBalancingStrategy_p2c_description": "Pick two random targets and send the request to the one with fewer active connections. Requires Traefik v3.4 or newer.",
|
||||||
|
"loadBalancingStrategy_hrw": "Client IP Hash",
|
||||||
|
"loadBalancingStrategy_hrw_description": "Hash the client IP so the same client is consistently routed to the same target. Requires Traefik v3.6 or newer.",
|
||||||
|
"loadBalancingStrategy_leasttime": "Least Response Time",
|
||||||
|
"loadBalancingStrategy_leasttime_description": "Send requests to the target with the fastest response time and fewest active connections. Requires Traefik v3.6 or newer.",
|
||||||
"methodSelect": "Select method",
|
"methodSelect": "Select method",
|
||||||
"targetSubmit": "Add Target",
|
"targetSubmit": "Add Target",
|
||||||
"targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.",
|
"targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.",
|
||||||
|
|||||||
@@ -181,6 +181,10 @@ export const resources = pgTable(
|
|||||||
applyRules: boolean("applyRules"),
|
applyRules: boolean("applyRules"),
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
stickySession: boolean("stickySession").notNull().default(false),
|
stickySession: boolean("stickySession").notNull().default(false),
|
||||||
|
loadBalancingStrategy: varchar("loadBalancingStrategy", { length: 32 })
|
||||||
|
.$type<"wrr" | "p2c" | "hrw" | "leasttime">()
|
||||||
|
.notNull()
|
||||||
|
.default("wrr"), // traefik http loadBalancer.strategy
|
||||||
tlsServerName: varchar("tlsServerName"),
|
tlsServerName: varchar("tlsServerName"),
|
||||||
setHostHeader: varchar("setHostHeader"),
|
setHostHeader: varchar("setHostHeader"),
|
||||||
enableProxy: boolean("enableProxy").default(true),
|
enableProxy: boolean("enableProxy").default(true),
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ export const resources = sqliteTable(
|
|||||||
stickySession: integer("stickySession", { mode: "boolean" })
|
stickySession: integer("stickySession", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
loadBalancingStrategy: text("loadBalancingStrategy")
|
||||||
|
.$type<"wrr" | "p2c" | "hrw" | "leasttime">()
|
||||||
|
.notNull()
|
||||||
|
.default("wrr"), // traefik http loadBalancer.strategy
|
||||||
tlsServerName: text("tlsServerName"),
|
tlsServerName: text("tlsServerName"),
|
||||||
setHostHeader: text("setHostHeader"),
|
setHostHeader: text("setHostHeader"),
|
||||||
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
|
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
|
||||||
|
|||||||
@@ -410,6 +410,13 @@ export async function generatePublicResourceBlueprintYaml(
|
|||||||
if (resource.tlsServerName) {
|
if (resource.tlsServerName) {
|
||||||
resourceConfig["tls-server-name"] = resource.tlsServerName;
|
resourceConfig["tls-server-name"] = resource.tlsServerName;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
resource.loadBalancingStrategy &&
|
||||||
|
resource.loadBalancingStrategy !== "wrr"
|
||||||
|
) {
|
||||||
|
resourceConfig["load-balancing-strategy"] =
|
||||||
|
resource.loadBalancingStrategy;
|
||||||
|
}
|
||||||
if (resource.requestHeaders) {
|
if (resource.requestHeaders) {
|
||||||
try {
|
try {
|
||||||
resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders);
|
resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders);
|
||||||
|
|||||||
@@ -424,6 +424,9 @@ export async function updatePublicResources(
|
|||||||
setHostHeader: resourceData["host-header"] || null,
|
setHostHeader: resourceData["host-header"] || null,
|
||||||
tlsServerName:
|
tlsServerName:
|
||||||
resourceData["tls-server-name"] || null,
|
resourceData["tls-server-name"] || null,
|
||||||
|
loadBalancingStrategy:
|
||||||
|
resourceData["load-balancing-strategy"] ??
|
||||||
|
"wrr",
|
||||||
emailWhitelistEnabled: resourceData.auth?.[
|
emailWhitelistEnabled: resourceData.auth?.[
|
||||||
"whitelist-users"
|
"whitelist-users"
|
||||||
]
|
]
|
||||||
@@ -628,6 +631,9 @@ export async function updatePublicResources(
|
|||||||
setHostHeader: resourceData["host-header"] || null,
|
setHostHeader: resourceData["host-header"] || null,
|
||||||
tlsServerName:
|
tlsServerName:
|
||||||
resourceData["tls-server-name"] || null,
|
resourceData["tls-server-name"] || null,
|
||||||
|
loadBalancingStrategy:
|
||||||
|
resourceData["load-balancing-strategy"] ??
|
||||||
|
"wrr",
|
||||||
requestHeaders,
|
requestHeaders,
|
||||||
responseHeaders,
|
responseHeaders,
|
||||||
maintenanceModeEnabled:
|
maintenanceModeEnabled:
|
||||||
@@ -1260,6 +1266,8 @@ export async function updatePublicResources(
|
|||||||
enabled: resourceEnabled,
|
enabled: resourceEnabled,
|
||||||
setHostHeader: resourceData["host-header"] || null,
|
setHostHeader: resourceData["host-header"] || null,
|
||||||
tlsServerName: resourceData["tls-server-name"] || null,
|
tlsServerName: resourceData["tls-server-name"] || null,
|
||||||
|
loadBalancingStrategy:
|
||||||
|
resourceData["load-balancing-strategy"] ?? "wrr",
|
||||||
ssl: resourceSsl,
|
ssl: resourceSsl,
|
||||||
requestHeaders,
|
requestHeaders,
|
||||||
responseHeaders,
|
responseHeaders,
|
||||||
|
|||||||
@@ -327,6 +327,9 @@ export const PublicResourceSchema = z
|
|||||||
auth: AuthSchema.optional(),
|
auth: AuthSchema.optional(),
|
||||||
"host-header": z.string().optional(),
|
"host-header": z.string().optional(),
|
||||||
"tls-server-name": z.string().optional(),
|
"tls-server-name": z.string().optional(),
|
||||||
|
"load-balancing-strategy": z
|
||||||
|
.enum(["wrr", "p2c", "hrw", "leasttime"])
|
||||||
|
.optional(),
|
||||||
headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders
|
headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders
|
||||||
requestHeaders: z.array(HeaderSchema).optional(),
|
requestHeaders: z.array(HeaderSchema).optional(),
|
||||||
responseHeaders: z.array(HeaderSchema).optional(),
|
responseHeaders: z.array(HeaderSchema).optional(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { buildWildcardTls } from "./certResolver";
|
|||||||
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
|
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
|
||||||
import {
|
import {
|
||||||
buildHttpLoadBalancerServers,
|
buildHttpLoadBalancerServers,
|
||||||
|
buildLoadBalancingStrategy,
|
||||||
buildStickySessionCookie,
|
buildStickySessionCookie,
|
||||||
buildTcpUdpLoadBalancerServers,
|
buildTcpUdpLoadBalancerServers,
|
||||||
buildStickySessionIp
|
buildStickySessionIp
|
||||||
@@ -93,6 +94,7 @@ export async function getTraefikConfig(
|
|||||||
domainId: resources.domainId,
|
domainId: resources.domainId,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
stickySession: resources.stickySession,
|
stickySession: resources.stickySession,
|
||||||
|
loadBalancingStrategy: resources.loadBalancingStrategy,
|
||||||
tlsServerName: resources.tlsServerName,
|
tlsServerName: resources.tlsServerName,
|
||||||
setHostHeader: resources.setHostHeader,
|
setHostHeader: resources.setHostHeader,
|
||||||
enableProxy: resources.enableProxy,
|
enableProxy: resources.enableProxy,
|
||||||
@@ -222,6 +224,7 @@ export async function getTraefikConfig(
|
|||||||
domainId: row.domainId,
|
domainId: row.domainId,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
stickySession: row.stickySession,
|
stickySession: row.stickySession,
|
||||||
|
loadBalancingStrategy: row.loadBalancingStrategy,
|
||||||
tlsServerName: row.tlsServerName,
|
tlsServerName: row.tlsServerName,
|
||||||
setHostHeader: row.setHostHeader,
|
setHostHeader: row.setHostHeader,
|
||||||
enableProxy: row.enableProxy,
|
enableProxy: row.enableProxy,
|
||||||
@@ -458,6 +461,9 @@ export async function getTraefikConfig(
|
|||||||
config_output.http.services![serviceName] = {
|
config_output.http.services![serviceName] = {
|
||||||
loadBalancer: {
|
loadBalancer: {
|
||||||
servers: buildHttpLoadBalancerServers(targets),
|
servers: buildHttpLoadBalancerServers(targets),
|
||||||
|
...buildLoadBalancingStrategy(
|
||||||
|
resource.loadBalancingStrategy
|
||||||
|
),
|
||||||
...(resource.stickySession
|
...(resource.stickySession
|
||||||
? buildStickySessionCookie(resource.ssl)
|
? buildStickySessionCookie(resource.ssl)
|
||||||
: {})
|
: {})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "@server/extendZod";
|
|||||||
import { assertEqualsObj } from "@test/assert";
|
import { assertEqualsObj } from "@test/assert";
|
||||||
import {
|
import {
|
||||||
buildHttpLoadBalancerServers,
|
buildHttpLoadBalancerServers,
|
||||||
|
buildLoadBalancingStrategy,
|
||||||
buildTcpUdpLoadBalancerServers
|
buildTcpUdpLoadBalancerServers
|
||||||
} from "./loadBalancer";
|
} from "./loadBalancer";
|
||||||
import type { TargetWithSite } from "./types";
|
import type { TargetWithSite } from "./types";
|
||||||
@@ -75,6 +76,24 @@ function runTests() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertEqualsObj(
|
||||||
|
buildLoadBalancingStrategy("wrr"),
|
||||||
|
{},
|
||||||
|
"Default wrr strategy should be omitted"
|
||||||
|
);
|
||||||
|
assertEqualsObj(
|
||||||
|
buildLoadBalancingStrategy(null),
|
||||||
|
{},
|
||||||
|
"Missing strategy should be omitted"
|
||||||
|
);
|
||||||
|
for (const strategy of ["p2c", "hrw", "leasttime"]) {
|
||||||
|
assertEqualsObj(
|
||||||
|
buildLoadBalancingStrategy(strategy),
|
||||||
|
{ strategy },
|
||||||
|
`${strategy} strategy should be emitted`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("All load balancer endpoint tests passed!");
|
console.log("All load balancer endpoint tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ export function buildHttpLoadBalancerServers(targets: TargetWithSite[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the loadBalancer.strategy for an HTTP-mode resource. Traefik defaults
|
||||||
|
* to wrr when strategy is omitted, so only emit it for the non-default
|
||||||
|
* strategies to keep the generated config unchanged for existing resources.
|
||||||
|
*/
|
||||||
|
export function buildLoadBalancingStrategy(strategy: string | null) {
|
||||||
|
if (!strategy || strategy === "wrr") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return { strategy };
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStickySessionCookie(ssl: boolean | null) {
|
export function buildStickySessionCookie(ssl: boolean | null) {
|
||||||
return {
|
return {
|
||||||
sticky: {
|
sticky: {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
} from "@server/lib/traefik/rule";
|
} from "@server/lib/traefik/rule";
|
||||||
import {
|
import {
|
||||||
buildHttpLoadBalancerServers,
|
buildHttpLoadBalancerServers,
|
||||||
|
buildLoadBalancingStrategy,
|
||||||
buildStickySessionCookie,
|
buildStickySessionCookie,
|
||||||
buildTcpUdpLoadBalancerServers,
|
buildTcpUdpLoadBalancerServers,
|
||||||
buildStickySessionIp
|
buildStickySessionIp
|
||||||
@@ -131,6 +132,7 @@ export async function getTraefikConfig(
|
|||||||
domainId: resources.domainId,
|
domainId: resources.domainId,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
stickySession: resources.stickySession,
|
stickySession: resources.stickySession,
|
||||||
|
loadBalancingStrategy: resources.loadBalancingStrategy,
|
||||||
tlsServerName: resources.tlsServerName,
|
tlsServerName: resources.tlsServerName,
|
||||||
setHostHeader: resources.setHostHeader,
|
setHostHeader: resources.setHostHeader,
|
||||||
enableProxy: resources.enableProxy,
|
enableProxy: resources.enableProxy,
|
||||||
@@ -275,6 +277,7 @@ export async function getTraefikConfig(
|
|||||||
domainId: row.domainId,
|
domainId: row.domainId,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
stickySession: row.stickySession,
|
stickySession: row.stickySession,
|
||||||
|
loadBalancingStrategy: row.loadBalancingStrategy,
|
||||||
tlsServerName: row.tlsServerName,
|
tlsServerName: row.tlsServerName,
|
||||||
setHostHeader: row.setHostHeader,
|
setHostHeader: row.setHostHeader,
|
||||||
enableProxy: row.enableProxy,
|
enableProxy: row.enableProxy,
|
||||||
@@ -639,6 +642,9 @@ export async function getTraefikConfig(
|
|||||||
config_output.http.services![serviceName] = {
|
config_output.http.services![serviceName] = {
|
||||||
loadBalancer: {
|
loadBalancer: {
|
||||||
servers: buildHttpLoadBalancerServers(targets),
|
servers: buildHttpLoadBalancerServers(targets),
|
||||||
|
...buildLoadBalancingStrategy(
|
||||||
|
resource.loadBalancingStrategy
|
||||||
|
),
|
||||||
...(resource.stickySession
|
...(resource.stickySession
|
||||||
? buildStickySessionCookie(resource.ssl)
|
? buildStickySessionCookie(resource.ssl)
|
||||||
: {})
|
: {})
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ const updateHttpResourceBodySchema = z
|
|||||||
domainId: z.string().optional(),
|
domainId: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
stickySession: z.boolean().optional(),
|
stickySession: z.boolean().optional(),
|
||||||
|
loadBalancingStrategy: z
|
||||||
|
.enum(["wrr", "p2c", "hrw", "leasttime"])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"How requests are distributed across targets: wrr (weighted round robin, default), p2c (power of two choices), hrw (highest random weight / client IP hashing), leasttime (lowest response time)."
|
||||||
|
),
|
||||||
tlsServerName: z.string().nullable().optional(),
|
tlsServerName: z.string().nullable().optional(),
|
||||||
setHostHeader: z.string().nullable().optional(),
|
setHostHeader: z.string().nullable().optional(),
|
||||||
skipToIdpId: z
|
skipToIdpId: z
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
import { HeadersInput } from "@app/components/HeadersInput";
|
import { HeadersInput } from "@app/components/HeadersInput";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
@@ -45,6 +52,8 @@ import { useActionState, useState, startTransition } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const LOAD_BALANCING_STRATEGIES = ["wrr", "p2c", "hrw", "leasttime"] as const;
|
||||||
|
|
||||||
export default function ReverseProxyTargetsPage() {
|
export default function ReverseProxyTargetsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
@@ -90,6 +99,7 @@ function ProxyResourceHttpForm({
|
|||||||
|
|
||||||
const httpSettingsSchema = z.object({
|
const httpSettingsSchema = z.object({
|
||||||
stickySession: z.boolean(),
|
stickySession: z.boolean(),
|
||||||
|
loadBalancingStrategy: z.enum(LOAD_BALANCING_STRATEGIES),
|
||||||
ssl: z.boolean(),
|
ssl: z.boolean(),
|
||||||
tlsServerName: z
|
tlsServerName: z
|
||||||
.string()
|
.string()
|
||||||
@@ -131,6 +141,7 @@ function ProxyResourceHttpForm({
|
|||||||
resolver: zodResolver(httpSettingsSchema),
|
resolver: zodResolver(httpSettingsSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
stickySession: resource.stickySession,
|
stickySession: resource.stickySession,
|
||||||
|
loadBalancingStrategy: resource.loadBalancingStrategy,
|
||||||
ssl: resource.ssl,
|
ssl: resource.ssl,
|
||||||
tlsServerName: resource.tlsServerName || "",
|
tlsServerName: resource.tlsServerName || "",
|
||||||
setHostHeader: resource.setHostHeader || "",
|
setHostHeader: resource.setHostHeader || "",
|
||||||
@@ -164,6 +175,7 @@ function ProxyResourceHttpForm({
|
|||||||
`/resource/${resource.resourceId}`,
|
`/resource/${resource.resourceId}`,
|
||||||
{
|
{
|
||||||
stickySession: data.stickySession,
|
stickySession: data.stickySession,
|
||||||
|
loadBalancingStrategy: data.loadBalancingStrategy,
|
||||||
ssl: data.ssl,
|
ssl: data.ssl,
|
||||||
tlsServerName: data.tlsServerName || null,
|
tlsServerName: data.tlsServerName || null,
|
||||||
setHostHeader: data.setHostHeader || null,
|
setHostHeader: data.setHostHeader || null,
|
||||||
@@ -186,6 +198,7 @@ function ProxyResourceHttpForm({
|
|||||||
updateResource({
|
updateResource({
|
||||||
...resource,
|
...resource,
|
||||||
stickySession: data.stickySession,
|
stickySession: data.stickySession,
|
||||||
|
loadBalancingStrategy: data.loadBalancingStrategy,
|
||||||
ssl: data.ssl,
|
ssl: data.ssl,
|
||||||
tlsServerName: data.tlsServerName || null,
|
tlsServerName: data.tlsServerName || null,
|
||||||
setHostHeader: data.setHostHeader || null,
|
setHostHeader: data.setHostHeader || null,
|
||||||
@@ -305,6 +318,58 @@ function ProxyResourceHttpForm({
|
|||||||
/>
|
/>
|
||||||
</SettingsFormCell>
|
</SettingsFormCell>
|
||||||
|
|
||||||
|
<SettingsFormCell span="half">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="loadBalancingStrategy"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"loadBalancingStrategy"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{LOAD_BALANCING_STRATEGIES.map(
|
||||||
|
(strategy) => (
|
||||||
|
<SelectItem
|
||||||
|
key={
|
||||||
|
strategy
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
strategy
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
`loadBalancingStrategy_${strategy}`
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
`loadBalancingStrategy_${field.value}_description`
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
<SettingsFormCell span="half">
|
<SettingsFormCell span="half">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
Reference in New Issue
Block a user