Merge branch 'feat/add-proxy-protocol-support' into dev

This commit is contained in:
Owen
2025-10-26 18:16:38 -07:00
11 changed files with 218 additions and 40 deletions

View File

@@ -51,3 +51,12 @@ http:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://pangolin:3000" # API/WebSocket server - url: "http://pangolin:3000" # API/WebSocket server
tcp:
serversTransports:
pp-transport-v1:
proxyProtocol:
version: 1
pp-transport-v2:
proxyProtocol:
version: 2

View File

@@ -1911,5 +1911,15 @@
"orgOrDomainIdMissing": "Organization or Domain ID is missing", "orgOrDomainIdMissing": "Organization or Domain ID is missing",
"loadingDNSRecords": "Loading DNS records...", "loadingDNSRecords": "Loading DNS records...",
"olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.",
"client": "Client" "client": "Client",
"proxyProtocol": "Proxy Protocol Settings",
"proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP/UDP services.",
"enableProxyProtocol": "Enable Proxy Protocol",
"proxyProtocolInfo": "Preserve client IP addresses for TCP/UDP backends",
"proxyProtocolVersion": "Proxy Protocol Version",
"version1": " Version 1 (Recommended)",
"version2": "Version 2",
"versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible.",
"warning": "Warning",
"proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections. Make sure to configure your backend to trust Proxy Protocol headers from Traefik."
} }

View File

@@ -116,7 +116,9 @@ export const resources = pgTable("resources", {
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
headers: text("headers") // comma-separated list of headers to add to the request headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1)
}); });
export const targets = pgTable("targets", { export const targets = pgTable("targets", {

View File

@@ -128,7 +128,10 @@ export const resources = sqliteTable("resources", {
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "cascade" onDelete: "cascade"
}), }),
headers: text("headers") // comma-separated list of headers to add to the request headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: integer("proxyProtocol", { mode: "boolean" }).notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1)
}); });
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {

View File

@@ -309,10 +309,7 @@ export class TraefikConfigManager {
this.lastActiveDomains = new Set(domains); this.lastActiveDomains = new Set(domains);
} }
if ( if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") {
process.env.USE_PANGOLIN_DNS === "true" &&
build != "oss"
) {
// Scan current local certificate state // Scan current local certificate state
this.lastLocalCertificateState = this.lastLocalCertificateState =
await this.scanLocalCertificateState(); await this.scanLocalCertificateState();
@@ -450,7 +447,8 @@ export class TraefikConfigManager {
currentExitNode, currentExitNode,
config.getRawConfig().traefik.site_types, config.getRawConfig().traefik.site_types,
build == "oss", // filter out the namespace domains in open source build == "oss", // filter out the namespace domains in open source
build != "oss" // generate the login pages on the cloud and hybrid build != "oss", // generate the login pages on the cloud and hybrid,
build == "saas" ? false : config.getRawConfig().traefik.allow_raw_resources // dont allow raw resources on saas otherwise use config
); );
const domains = new Set<string>(); const domains = new Set<string>();
@@ -502,6 +500,25 @@ export class TraefikConfigManager {
}; };
} }
// tcp:
// serversTransports:
// pp-transport-v1:
// proxyProtocol:
// version: 1
// pp-transport-v2:
// proxyProtocol:
// version: 2
if (build != "saas") {
// add the serversTransports section if not present
if (traefikConfig.tcp && !traefikConfig.tcp.serversTransports) {
traefikConfig.tcp.serversTransports = {
"pp-transport-v1": { proxyProtocol: { version: 1 } },
"pp-transport-v2": { proxyProtocol: { version: 2 } }
};
}
}
return { domains, traefikConfig }; return { domains, traefikConfig };
} catch (error) { } catch (error) {
// pull data out of the axios error to log // pull data out of the axios error to log

View File

@@ -23,7 +23,8 @@ export async function getTraefikConfig(
exitNodeId: number, exitNodeId: number,
siteTypes: string[], siteTypes: string[],
filterOutNamespaceDomains = false, filterOutNamespaceDomains = false,
generateLoginPageRouters = false generateLoginPageRouters = false,
allowRawResources = true
): Promise<any> { ): Promise<any> {
// Define extended target type with site information // Define extended target type with site information
type TargetWithSite = Target & { type TargetWithSite = Target & {
@@ -56,6 +57,8 @@ export async function getTraefikConfig(
setHostHeader: resources.setHostHeader, setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy, enableProxy: resources.enableProxy,
headers: resources.headers, headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
// Target fields // Target fields
targetId: targets.targetId, targetId: targets.targetId,
targetEnabled: targets.enabled, targetEnabled: targets.enabled,
@@ -104,7 +107,7 @@ export async function getTraefikConfig(
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes),
config.getRawConfig().traefik.allow_raw_resources allowRawResources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true) : eq(resources.http, true)
) )
@@ -167,6 +170,8 @@ export async function getTraefikConfig(
enableProxy: row.enableProxy, enableProxy: row.enableProxy,
targets: [], targets: [],
headers: row.headers, headers: row.headers,
proxyProtocol: row.proxyProtocol,
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
path: row.path, // the targets will all have the same path path: row.path, // the targets will all have the same path
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
rewritePath: row.rewritePath, rewritePath: row.rewritePath,
@@ -635,6 +640,11 @@ export async function getTraefikConfig(
} }
}); });
})(), })(),
...(resource.proxyProtocol && protocol == "tcp"
? {
serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}`
}
: {}),
...(resource.stickySession ...(resource.stickySession
? { ? {
sticky: { sticky: {

View File

@@ -51,7 +51,8 @@ export async function getTraefikConfig(
exitNodeId: number, exitNodeId: number,
siteTypes: string[], siteTypes: string[],
filterOutNamespaceDomains = false, filterOutNamespaceDomains = false,
generateLoginPageRouters = false generateLoginPageRouters = false,
allowRawResources = true
): Promise<any> { ): Promise<any> {
// Define extended target type with site information // Define extended target type with site information
type TargetWithSite = Target & { type TargetWithSite = Target & {
@@ -141,7 +142,7 @@ export async function getTraefikConfig(
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes),
config.getRawConfig().traefik.allow_raw_resources allowRawResources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true) : eq(resources.http, true)
) )
@@ -709,6 +710,11 @@ export async function getTraefikConfig(
} }
}); });
})(), })(),
...(resource.proxyProtocol && protocol == "tcp" // proxy protocol only works for tcp
? {
serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}`
}
: {}),
...(resource.stickySession ...(resource.stickySession
? { ? {
sticky: { sticky: {

View File

@@ -270,7 +270,8 @@ hybridRouter.get(
remoteExitNode.exitNodeId, remoteExitNode.exitNodeId,
["newt", "local", "wireguard"], // Allow them to use all the site types ["newt", "local", "wireguard"], // Allow them to use all the site types
true, // But don't allow domain namespace resources true, // But don't allow domain namespace resources
false // Dont include login pages false, // Dont include login pages,
true // allow raw resources
); );
return response(res, { return response(res, {

View File

@@ -99,8 +99,9 @@ const updateRawResourceBodySchema = z
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
proxyPort: z.number().int().min(1).max(65535).optional(), proxyPort: z.number().int().min(1).max(65535).optional(),
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
enabled: z.boolean().optional() enabled: z.boolean().optional(),
// enableProxy: z.boolean().optional() // always true now proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.number().int().min(1).optional()
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {

View File

@@ -21,7 +21,8 @@ export async function traefikConfigProvider(
currentExitNodeId, currentExitNodeId,
config.getRawConfig().traefik.site_types, config.getRawConfig().traefik.site_types,
build == "oss", // filter out the namespace domains in open source build == "oss", // filter out the namespace domains in open source
build != "oss" // generate the login pages on the cloud and hybrid build != "oss", // generate the login pages on the cloud and and enterprise,
config.getRawConfig().traefik.allow_raw_resources
); );
if (traefikConfig?.http?.middlewares) { if (traefikConfig?.http?.middlewares) {

View File

@@ -77,7 +77,8 @@ import {
MoveRight, MoveRight,
ArrowUp, ArrowUp,
Info, Info,
ArrowDown ArrowDown,
AlertTriangle
} from "lucide-react"; } from "lucide-react";
import { ContainersSelector } from "@app/components/ContainersSelector"; import { ContainersSelector } from "@app/components/ContainersSelector";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -115,6 +116,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from "@app/components/ui/tooltip"; } from "@app/components/ui/tooltip";
import { Alert, AlertDescription } from "@app/components/ui/alert";
const addTargetSchema = z const addTargetSchema = z
.object({ .object({
@@ -288,7 +290,9 @@ export default function ReverseProxyTargets(props: {
), ),
headers: z headers: z
.array(z.object({ name: z.string(), value: z.string() })) .array(z.object({ name: z.string(), value: z.string() }))
.nullable() .nullable(),
proxyProtocol: z.boolean().optional(),
proxyProtocolVersion: z.number().int().min(1).max(2).optional()
}); });
const tlsSettingsSchema = z.object({ const tlsSettingsSchema = z.object({
@@ -325,7 +329,9 @@ export default function ReverseProxyTargets(props: {
resolver: zodResolver(proxySettingsSchema), resolver: zodResolver(proxySettingsSchema),
defaultValues: { defaultValues: {
setHostHeader: resource.setHostHeader || "", setHostHeader: resource.setHostHeader || "",
headers: resource.headers headers: resource.headers,
proxyProtocol: resource.proxyProtocol || false,
proxyProtocolVersion: resource.proxyProtocolVersion || 1
} }
}); });
@@ -549,11 +555,11 @@ export default function ReverseProxyTargets(props: {
prev.map((t) => prev.map((t) =>
t.targetId === target.targetId t.targetId === target.targetId
? { ? {
...t, ...t,
targetId: response.data.data.targetId, targetId: response.data.data.targetId,
new: false, new: false,
updated: false updated: false
} }
: t : t
) )
); );
@@ -673,11 +679,11 @@ export default function ReverseProxyTargets(props: {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...data, ...data,
updated: true, updated: true,
siteType: site ? site.type : target.siteType siteType: site ? site.type : target.siteType
} }
: target : target
) )
); );
@@ -688,10 +694,10 @@ export default function ReverseProxyTargets(props: {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...config, ...config,
updated: true updated: true
} }
: target : target
) )
); );
@@ -800,6 +806,22 @@ export default function ReverseProxyTargets(props: {
setHostHeader: proxyData.setHostHeader || null, setHostHeader: proxyData.setHostHeader || null,
headers: proxyData.headers || null headers: proxyData.headers || null
}); });
} else {
// For TCP/UDP resources, save proxy protocol settings
const proxyData = proxySettingsForm.getValues();
const payload = {
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
};
await api.post(`/resource/${resource.resourceId}`, payload);
updateResource({
...resource,
proxyProtocol: proxyData.proxyProtocol || false,
proxyProtocolVersion: proxyData.proxyProtocolVersion || 1
});
} }
toast({ toast({
@@ -1064,7 +1086,7 @@ export default function ReverseProxyTargets(props: {
className={cn( className={cn(
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent", "w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId && !row.original.siteId &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
<span className="truncate max-w-[150px]"> <span className="truncate max-w-[150px]">
@@ -1404,12 +1426,12 @@ export default function ReverseProxyTargets(props: {
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header header
.column .column
.columnDef .columnDef
.header, .header,
header.getContext() header.getContext()
)} )}
</TableHead> </TableHead>
) )
)} )}
@@ -1675,6 +1697,102 @@ export default function ReverseProxyTargets(props: {
</SettingsSection> </SettingsSection>
)} )}
{!resource.http && resource.protocol && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("proxyProtocol")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("proxyProtocolDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
onSubmit={proxySettingsForm.handleSubmit(
saveAllSettings
)}
className="space-y-4"
id="proxy-protocol-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="proxyProtocol"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="proxy-protocol-toggle"
label={t(
"enableProxyProtocol"
)}
description={t(
"proxyProtocolInfo"
)}
defaultChecked={
field.value || false
}
onCheckedChange={(val) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
{proxySettingsForm.watch("proxyProtocol") && (
<>
<FormField
control={proxySettingsForm.control}
name="proxyProtocolVersion"
render={({ field }) => (
<FormItem>
<FormLabel>{t("proxyProtocolVersion")}</FormLabel>
<FormControl>
<Select
value={String(field.value || 1)}
onValueChange={(value) =>
field.onChange(parseInt(value, 10))
}
>
<SelectTrigger>
<SelectValue placeholder="Select version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">
{t("version1")}
</SelectItem>
<SelectItem value="2">
{t("version2")}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("versionDescription")}
</FormDescription>
</FormItem>
)}
/>
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>{t("warning")}:</strong> {t("proxyProtocolWarning")}
</AlertDescription>
</Alert>
</>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
<div className="flex justify-end mt-6"> <div className="flex justify-end mt-6">
<Button <Button
onClick={saveAllSettings} onClick={saveAllSettings}