From 7291628f8640ca90183a34a03956bee69735c70f Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 13 Aug 2026 09:14:37 -0400 Subject: [PATCH] optionally stamp ip header for downstream use --- server/lib/aiGatewayTrust.ts | 9 +++++++ server/lib/readConfigFile.ts | 22 +++++++++++++++- server/lib/traefik/getTraefikConfig.ts | 26 +++++++++++++++++++ .../private/lib/traefik/getTraefikConfig.ts | 26 +++++++++++++++++++ server/routers/aiGateway/pipeline.ts | 11 ++++++-- 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/server/lib/aiGatewayTrust.ts b/server/lib/aiGatewayTrust.ts index 704fbf4fb..b8c165a8c 100644 --- a/server/lib/aiGatewayTrust.ts +++ b/server/lib/aiGatewayTrust.ts @@ -12,6 +12,15 @@ export const AI_GATEWAY_RESOURCE_TYPE_HEADER = export type AiGatewayResourceType = "resource" | "site-resource"; +// Opt-in (server.enable_ai_gateway_client_ip_header): carries the client IP +// that Badger resolved at the Traefik hop, so it survives an intermediary +// proxy between Traefik and the AI gateway that overwrites +// X-Forwarded-For/X-Real-Ip instead of appending to them. Set by a +// disableForwardAuth Badger middleware instance (see getTraefikConfig.ts) +// on the site-resource inference router only, since that's the sole path +// that resolves request identity from the client IP. +export const AI_GATEWAY_CLIENT_IP_HEADER = "X-Pangolin-Client-Ip"; + /** * Derive a Traefik-injected trust token from the server secret. * Traefik overwrites this header on inference routes so the AI gateway can diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index ba8c9c5ba..adb60ef4a 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -153,6 +153,25 @@ export const configSchema = z }) .optional(), trust_proxy: z.int().gte(0).optional().default(1), + // Opt-in: have Traefik/Badger stamp the resolved client IP + // into a dedicated header (X-Pangolin-Client-Ip) on the + // site-resource AI gateway route, so it survives an + // intermediary proxy between Traefik and the gateway that + // overwrites X-Forwarded-For/X-Real-Ip instead of appending + // to them. Off by default since it requires a Badger + // version that supports realIpHeader. + enable_ai_gateway_client_ip_header: z + .boolean() + .optional() + .default(false) + .transform((val) => + process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !== + undefined + ? process.env + .ENABLE_AI_GATEWAY_CLIENT_IP_HEADER === + "true" + : val + ), secret: z.string().pipe(z.string().min(8)).optional(), maxmind_db_path: z.string().optional(), maxmind_asn_path: z.string().optional() @@ -183,7 +202,8 @@ export const configSchema = z "resource_session_request_param", dashboard_session_length_hours: 720, resource_session_length_hours: 720, - trust_proxy: 1 + trust_proxy: 1, + enable_ai_gateway_client_ip_header: false }), postgres: z .object({ diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 956a9c6c7..d0d553910 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -27,6 +27,7 @@ import regionalCache from "@server/lib/cache"; import { AI_GATEWAY_TRUST_HEADER, AI_GATEWAY_RESOURCE_TYPE_HEADER, + AI_GATEWAY_CLIENT_IP_HEADER, getAiGatewayTrustToken } from "@server/lib/aiGatewayTrust"; @@ -786,6 +787,28 @@ export async function getTraefikConfig( } }; + // Opt-in: a Badger instance with forward auth disabled, used only + // to stamp the resolved client IP into a dedicated header before + // the request reaches whatever sits between Traefik and the AI + // gateway. Only the site-resource router below needs this - it's + // the only path that resolves request identity from the client IP + // (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the + // only inference router that doesn't already run Badger. + const aiGatewayClientIpMiddlewareName = "ai-gateway-client-ip"; + const enableAiGatewayClientIpHeader = + config.getRawConfig().server.enable_ai_gateway_client_ip_header; + if (enableAiGatewayClientIpHeader) { + config_output.http.middlewares[aiGatewayClientIpMiddlewareName] = + { + plugin: { + badger: { + disableForwardAuth: true, + realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER + } + } + }; + } + // Public inference resources: same TLS/cert-resolver handling as // plain http-mode resources, but the service points at the AI // gateway instead of any real backend targets. @@ -958,6 +981,9 @@ export async function getTraefikConfig( const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ + ...(enableAiGatewayClientIpHeader + ? [aiGatewayClientIpMiddlewareName] + : []), aiGatewayTrustMiddlewareNameSiteResource, srHeadersMiddlewareName, ...additionalMiddlewares diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 26393bd2e..f90305bd1 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -62,6 +62,7 @@ import regionalCache from "#private/lib/cache"; import { AI_GATEWAY_TRUST_HEADER, AI_GATEWAY_RESOURCE_TYPE_HEADER, + AI_GATEWAY_CLIENT_IP_HEADER, getAiGatewayTrustToken } from "@server/lib/aiGatewayTrust"; @@ -1600,6 +1601,28 @@ export async function getTraefikConfig( } }; + // Opt-in: a Badger instance with forward auth disabled, used only + // to stamp the resolved client IP into a dedicated header before + // the request reaches whatever sits between Traefik and the AI + // gateway. Only the site-resource router below needs this - it's + // the only path that resolves request identity from the client IP + // (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the + // only inference router that doesn't already run Badger. + const aiGatewayClientIpMiddlewareName = "ai-gateway-client-ip"; + const enableAiGatewayClientIpHeader = + config.getRawConfig().server.enable_ai_gateway_client_ip_header; + if (enableAiGatewayClientIpHeader) { + config_output.http.middlewares[aiGatewayClientIpMiddlewareName] = + { + plugin: { + badger: { + disableForwardAuth: true, + realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER + } + } + }; + } + // Public inference resources: same TLS/cert-resolver handling as // plain http-mode resources, but the service points at the AI // gateway instead of any real backend targets. @@ -1770,6 +1793,9 @@ export async function getTraefikConfig( const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares: string[] = [ + ...(enableAiGatewayClientIpHeader + ? [aiGatewayClientIpMiddlewareName] + : []), aiGatewayTrustMiddlewareNameSiteResource ]; diff --git a/server/routers/aiGateway/pipeline.ts b/server/routers/aiGateway/pipeline.ts index 7604f086a..aa4f5bf5a 100644 --- a/server/routers/aiGateway/pipeline.ts +++ b/server/routers/aiGateway/pipeline.ts @@ -42,6 +42,7 @@ import { localCache } from "@server/lib/cache"; import { AI_GATEWAY_TRUST_HEADER, AI_GATEWAY_RESOURCE_TYPE_HEADER, + AI_GATEWAY_CLIENT_IP_HEADER, isAiGatewayTrustHeaderValid, getAiGatewayResourceType, type AiGatewayResourceType @@ -303,7 +304,12 @@ async function resolveRequestUser( return { user: null, virtualApiKeyId: null }; } - const ip = req.ip; + // Prefer the dedicated header Badger stamps at the Traefik hop (opt-in + // via server.enable_ai_gateway_client_ip_header) over req.ip, since an + // intermediary proxy between Traefik and this gateway may overwrite + // X-Forwarded-For/X-Real-Ip rather than appending to them, corrupting + // what req.ip resolves to. + const ip = getRequestHeader(req, AI_GATEWAY_CLIENT_IP_HEADER) || req.ip; if (!ip) { return { user: null, virtualApiKeyId: null }; } @@ -965,7 +971,8 @@ export async function handleAiGatewayProxy( "content-length", "accept-encoding", AI_GATEWAY_TRUST_HEADER.toLowerCase(), - AI_GATEWAY_RESOURCE_TYPE_HEADER.toLowerCase() + AI_GATEWAY_RESOURCE_TYPE_HEADER.toLowerCase(), + AI_GATEWAY_CLIENT_IP_HEADER.toLowerCase() ]); const headers: Record = {};