diff --git a/install/config/traefik/dynamic_config.yml b/install/config/traefik/dynamic_config.yml index 8fcf8e55..f795016b 100644 --- a/install/config/traefik/dynamic_config.yml +++ b/install/config/traefik/dynamic_config.yml @@ -51,3 +51,12 @@ http: loadBalancer: servers: - url: "http://pangolin:3000" # API/WebSocket server + +tcp: + serversTransports: + pp-transport-v1: + proxyProtocol: + version: 1 + pp-transport-v2: + proxyProtocol: + version: 2 \ No newline at end of file diff --git a/messages/en-US.json b/messages/en-US.json index 734466b1..6ac64aa1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1911,5 +1911,15 @@ "orgOrDomainIdMissing": "Organization or Domain ID is missing", "loadingDNSRecords": "Loading DNS records...", "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." } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 1d74d169..13761cd0 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -116,7 +116,9 @@ export const resources = pgTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { 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", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 50ecf3e7..6d504b9d 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -128,7 +128,10 @@ export const resources = sqliteTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { 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", { diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index ec4e25f4..56648559 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -309,10 +309,7 @@ export class TraefikConfigManager { this.lastActiveDomains = new Set(domains); } - if ( - process.env.USE_PANGOLIN_DNS === "true" && - build != "oss" - ) { + if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") { // Scan current local certificate state this.lastLocalCertificateState = await this.scanLocalCertificateState(); @@ -450,7 +447,8 @@ export class TraefikConfigManager { currentExitNode, config.getRawConfig().traefik.site_types, 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(); @@ -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 }; } catch (error) { // pull data out of the axios error to log diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 40550a69..4352173b 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -23,7 +23,8 @@ export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, - generateLoginPageRouters = false + generateLoginPageRouters = false, + allowRawResources = true ): Promise { // Define extended target type with site information type TargetWithSite = Target & { @@ -56,6 +57,8 @@ export async function getTraefikConfig( setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, headers: resources.headers, + proxyProtocol: resources.proxyProtocol, + proxyProtocolVersion: resources.proxyProtocolVersion, // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -104,7 +107,7 @@ export async function getTraefikConfig( isNull(targetHealthCheck.hcHealth) // Include targets with no health check record ), inArray(sites.type, siteTypes), - config.getRawConfig().traefik.allow_raw_resources + allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) @@ -167,6 +170,8 @@ export async function getTraefikConfig( enableProxy: row.enableProxy, targets: [], headers: row.headers, + proxyProtocol: row.proxyProtocol, + proxyProtocolVersion: row.proxyProtocolVersion ?? 1, path: row.path, // the targets will all have the same path pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, @@ -635,6 +640,11 @@ export async function getTraefikConfig( } }); })(), + ...(resource.proxyProtocol && protocol == "tcp" + ? { + serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}` + } + : {}), ...(resource.stickySession ? { sticky: { diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index d4bbcd4f..b8a2b32b 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -51,7 +51,8 @@ export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, - generateLoginPageRouters = false + generateLoginPageRouters = false, + allowRawResources = true ): Promise { // Define extended target type with site information type TargetWithSite = Target & { @@ -141,7 +142,7 @@ export async function getTraefikConfig( isNull(targetHealthCheck.hcHealth) // Include targets with no health check record ), inArray(sites.type, siteTypes), - config.getRawConfig().traefik.allow_raw_resources + allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is 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 ? { sticky: { diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index df99df92..abfbd02f 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -270,7 +270,8 @@ hybridRouter.get( remoteExitNode.exitNodeId, ["newt", "local", "wireguard"], // Allow them to use all the site types 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, { diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index a9c3b5de..13c5220d 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -99,8 +99,9 @@ const updateRawResourceBodySchema = z name: z.string().min(1).max(255).optional(), proxyPort: z.number().int().min(1).max(65535).optional(), stickySession: z.boolean().optional(), - enabled: z.boolean().optional() - // enableProxy: z.boolean().optional() // always true now + enabled: z.boolean().optional(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.number().int().min(1).optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index 6c9404e9..9b12ed8a 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -21,7 +21,8 @@ export async function traefikConfigProvider( currentExitNodeId, config.getRawConfig().traefik.site_types, 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) { diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 9588e0c8..5ef2ccd5 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -77,7 +77,8 @@ import { MoveRight, ArrowUp, Info, - ArrowDown + ArrowDown, + AlertTriangle } from "lucide-react"; import { ContainersSelector } from "@app/components/ContainersSelector"; import { useTranslations } from "next-intl"; @@ -115,6 +116,7 @@ import { TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; const addTargetSchema = z .object({ @@ -288,7 +290,9 @@ export default function ReverseProxyTargets(props: { ), headers: z .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({ @@ -325,7 +329,9 @@ export default function ReverseProxyTargets(props: { resolver: zodResolver(proxySettingsSchema), defaultValues: { 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) => t.targetId === target.targetId ? { - ...t, - targetId: response.data.data.targetId, - new: false, - updated: false - } + ...t, + targetId: response.data.data.targetId, + new: false, + updated: false + } : t ) ); @@ -673,11 +679,11 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site ? site.type : target.siteType - } + ...target, + ...data, + updated: true, + siteType: site ? site.type : target.siteType + } : target ) ); @@ -688,10 +694,10 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...config, - updated: true - } + ...target, + ...config, + updated: true + } : target ) ); @@ -800,6 +806,22 @@ export default function ReverseProxyTargets(props: { setHostHeader: proxyData.setHostHeader || 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({ @@ -1064,7 +1086,7 @@ export default function ReverseProxyTargets(props: { className={cn( "w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > @@ -1404,12 +1426,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1675,6 +1697,102 @@ export default function ReverseProxyTargets(props: { )} + {!resource.http && resource.protocol && ( + + + + {t("proxyProtocol")} + + + {t("proxyProtocolDescription")} + + + + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + {proxySettingsForm.watch("proxyProtocol") && ( + <> + ( + + {t("proxyProtocolVersion")} + + + + + {t("versionDescription")} + + + )} + /> + + + + + {t("warning")}: {t("proxyProtocolWarning")} + + + + )} + + +
+
+
+ )} +