From b43709903d6ad84a65ac3bf07c4d59cd7d40cbff Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 12 Oct 2026 16:47:12 -0400 Subject: [PATCH] Add support for configuring load balancing strategy --- messages/en-US.json | 9 +++ server/db/pg/schema/schema.ts | 4 ++ server/db/sqlite/schema/schema.ts | 4 ++ .../lib/blueprints/exportResourceBlueprint.ts | 7 ++ server/lib/blueprints/publicResources.ts | 8 +++ server/lib/blueprints/types.ts | 3 + server/lib/traefik/getTraefikConfig.ts | 6 ++ server/lib/traefik/loadBalancer.test.ts | 19 ++++++ server/lib/traefik/loadBalancer.ts | 12 ++++ .../private/lib/traefik/getTraefikConfig.ts | 6 ++ server/routers/resource/updateResource.ts | 6 ++ .../resources/public/[niceId]/http/page.tsx | 65 +++++++++++++++++++ 12 files changed, 149 insertions(+) diff --git a/messages/en-US.json b/messages/en-US.json index ea591febb..88302ecd7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -793,6 +793,15 @@ "targetsDescription": "Set up targets to route traffic to backend services", "targetStickySessions": "Enable Sticky Sessions", "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", "targetSubmit": "Add Target", "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 53b2db0d3..53f02b69e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -181,6 +181,10 @@ export const resources = pgTable( applyRules: boolean("applyRules"), enabled: boolean("enabled").notNull().default(true), 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"), setHostHeader: varchar("setHostHeader"), enableProxy: boolean("enableProxy").default(true), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f9ea8fd6c..72b323158 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -197,6 +197,10 @@ export const resources = sqliteTable( stickySession: integer("stickySession", { mode: "boolean" }) .notNull() .default(false), + loadBalancingStrategy: text("loadBalancingStrategy") + .$type<"wrr" | "p2c" | "hrw" | "leasttime">() + .notNull() + .default("wrr"), // traefik http loadBalancer.strategy tlsServerName: text("tlsServerName"), setHostHeader: text("setHostHeader"), enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), diff --git a/server/lib/blueprints/exportResourceBlueprint.ts b/server/lib/blueprints/exportResourceBlueprint.ts index 5b825e74c..9f3f74169 100644 --- a/server/lib/blueprints/exportResourceBlueprint.ts +++ b/server/lib/blueprints/exportResourceBlueprint.ts @@ -410,6 +410,13 @@ export async function generatePublicResourceBlueprintYaml( if (resource.tlsServerName) { resourceConfig["tls-server-name"] = resource.tlsServerName; } + if ( + resource.loadBalancingStrategy && + resource.loadBalancingStrategy !== "wrr" + ) { + resourceConfig["load-balancing-strategy"] = + resource.loadBalancingStrategy; + } if (resource.requestHeaders) { try { resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders); diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index ffd4331df..6a52a9872 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -424,6 +424,9 @@ export async function updatePublicResources( setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, + loadBalancingStrategy: + resourceData["load-balancing-strategy"] ?? + "wrr", emailWhitelistEnabled: resourceData.auth?.[ "whitelist-users" ] @@ -628,6 +631,9 @@ export async function updatePublicResources( setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, + loadBalancingStrategy: + resourceData["load-balancing-strategy"] ?? + "wrr", requestHeaders, responseHeaders, maintenanceModeEnabled: @@ -1260,6 +1266,8 @@ export async function updatePublicResources( enabled: resourceEnabled, setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, + loadBalancingStrategy: + resourceData["load-balancing-strategy"] ?? "wrr", ssl: resourceSsl, requestHeaders, responseHeaders, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index eb50906ca..94b9d380b 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -327,6 +327,9 @@ export const PublicResourceSchema = z auth: AuthSchema.optional(), "host-header": 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 requestHeaders: z.array(HeaderSchema).optional(), responseHeaders: z.array(HeaderSchema).optional(), diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 47f665794..81a42ab4b 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -30,6 +30,7 @@ import { buildWildcardTls } from "./certResolver"; import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule"; import { buildHttpLoadBalancerServers, + buildLoadBalancingStrategy, buildStickySessionCookie, buildTcpUdpLoadBalancerServers, buildStickySessionIp @@ -93,6 +94,7 @@ export async function getTraefikConfig( domainId: resources.domainId, enabled: resources.enabled, stickySession: resources.stickySession, + loadBalancingStrategy: resources.loadBalancingStrategy, tlsServerName: resources.tlsServerName, setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, @@ -222,6 +224,7 @@ export async function getTraefikConfig( domainId: row.domainId, enabled: row.enabled, stickySession: row.stickySession, + loadBalancingStrategy: row.loadBalancingStrategy, tlsServerName: row.tlsServerName, setHostHeader: row.setHostHeader, enableProxy: row.enableProxy, @@ -458,6 +461,9 @@ export async function getTraefikConfig( config_output.http.services![serviceName] = { loadBalancer: { servers: buildHttpLoadBalancerServers(targets), + ...buildLoadBalancingStrategy( + resource.loadBalancingStrategy + ), ...(resource.stickySession ? buildStickySessionCookie(resource.ssl) : {}) diff --git a/server/lib/traefik/loadBalancer.test.ts b/server/lib/traefik/loadBalancer.test.ts index 0ffd5201d..2adb5f040 100644 --- a/server/lib/traefik/loadBalancer.test.ts +++ b/server/lib/traefik/loadBalancer.test.ts @@ -2,6 +2,7 @@ import "@server/extendZod"; import { assertEqualsObj } from "@test/assert"; import { buildHttpLoadBalancerServers, + buildLoadBalancingStrategy, buildTcpUdpLoadBalancerServers } from "./loadBalancer"; 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!"); } diff --git a/server/lib/traefik/loadBalancer.ts b/server/lib/traefik/loadBalancer.ts index 538f6bf07..365756b37 100644 --- a/server/lib/traefik/loadBalancer.ts +++ b/server/lib/traefik/loadBalancer.ts @@ -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) { return { sticky: { diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 4c2b87906..51a879a42 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -66,6 +66,7 @@ import { } from "@server/lib/traefik/rule"; import { buildHttpLoadBalancerServers, + buildLoadBalancingStrategy, buildStickySessionCookie, buildTcpUdpLoadBalancerServers, buildStickySessionIp @@ -131,6 +132,7 @@ export async function getTraefikConfig( domainId: resources.domainId, enabled: resources.enabled, stickySession: resources.stickySession, + loadBalancingStrategy: resources.loadBalancingStrategy, tlsServerName: resources.tlsServerName, setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, @@ -275,6 +277,7 @@ export async function getTraefikConfig( domainId: row.domainId, enabled: row.enabled, stickySession: row.stickySession, + loadBalancingStrategy: row.loadBalancingStrategy, tlsServerName: row.tlsServerName, setHostHeader: row.setHostHeader, enableProxy: row.enableProxy, @@ -639,6 +642,9 @@ export async function getTraefikConfig( config_output.http.services![serviceName] = { loadBalancer: { servers: buildHttpLoadBalancerServers(targets), + ...buildLoadBalancingStrategy( + resource.loadBalancingStrategy + ), ...(resource.stickySession ? buildStickySessionCookie(resource.ssl) : {}) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 2716d8556..a366cc7a2 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -90,6 +90,12 @@ const updateHttpResourceBodySchema = z domainId: z.string().optional(), enabled: 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(), setHostHeader: z.string().nullable().optional(), skipToIdpId: z diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx index 40330e51b..a44165126 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx @@ -2,6 +2,13 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; import { HeadersInput } from "@app/components/HeadersInput"; import { SettingsContainer, @@ -45,6 +52,8 @@ import { useActionState, useState, startTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +const LOAD_BALANCING_STRATEGIES = ["wrr", "p2c", "hrw", "leasttime"] as const; + export default function ReverseProxyTargetsPage() { const params = useParams(); const { resource, updateResource } = useResourceContext(); @@ -90,6 +99,7 @@ function ProxyResourceHttpForm({ const httpSettingsSchema = z.object({ stickySession: z.boolean(), + loadBalancingStrategy: z.enum(LOAD_BALANCING_STRATEGIES), ssl: z.boolean(), tlsServerName: z .string() @@ -131,6 +141,7 @@ function ProxyResourceHttpForm({ resolver: zodResolver(httpSettingsSchema), defaultValues: { stickySession: resource.stickySession, + loadBalancingStrategy: resource.loadBalancingStrategy, ssl: resource.ssl, tlsServerName: resource.tlsServerName || "", setHostHeader: resource.setHostHeader || "", @@ -164,6 +175,7 @@ function ProxyResourceHttpForm({ `/resource/${resource.resourceId}`, { stickySession: data.stickySession, + loadBalancingStrategy: data.loadBalancingStrategy, ssl: data.ssl, tlsServerName: data.tlsServerName || null, setHostHeader: data.setHostHeader || null, @@ -186,6 +198,7 @@ function ProxyResourceHttpForm({ updateResource({ ...resource, stickySession: data.stickySession, + loadBalancingStrategy: data.loadBalancingStrategy, ssl: data.ssl, tlsServerName: data.tlsServerName || null, setHostHeader: data.setHostHeader || null, @@ -305,6 +318,58 @@ function ProxyResourceHttpForm({ /> + + ( + + + {t( + "loadBalancingStrategy" + )} + + + + {t( + `loadBalancingStrategy_${field.value}_description` + )} + + + + )} + /> + +