Pass 1 of pulling traefik config into functions

This commit is contained in:
Owen
2026-08-14 17:42:23 -04:00
parent 52f5ad6523
commit 0c0606b158
9 changed files with 792 additions and 1104 deletions
+165
View File
@@ -0,0 +1,165 @@
import config from "@server/lib/config";
import {
AI_GATEWAY_TRUST_HEADER,
AI_GATEWAY_RESOURCE_TYPE_HEADER,
AI_GATEWAY_CLIENT_IP_HEADER,
getAiGatewayTrustToken
} from "@server/lib/aiGatewayTrust";
// The trust token is the same for every inference route on an exit node, so
// these middlewares are built once and attached to each inference router.
// Two variants exist (public resource vs. siteResource) so the resource
// type header lets the gateway know which kind of router the request came
// through without re-deriving it from resourceId.
export const AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE =
"ai-gateway-trust-headers-resource";
export const AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE =
"ai-gateway-trust-headers-site-resource";
// 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 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.
export const AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME = "ai-gateway-client-ip";
/**
* The AI gateway may live on a different host than the inference resource
* itself (e.g. a remote exit node forwarding to the central dashboard over
* a tunnel), so callers use this to decide whether to pin the Host header
* to the gateway's own host.
*/
export function getAiGatewayHost(aiGatewayUrl: string): string | undefined {
try {
return new URL(aiGatewayUrl).host;
} catch {
return undefined;
}
}
/**
* Header middleware that pins the Host header to the AI gateway's own host
* (when it differs from the resource's) and smuggles the original resource
* host through in "p-host" instead, so passHostHeader can't leak the wrong
* Host to a gateway that lives on a different host than the resource.
*/
export function buildAiGatewayHostHeaderMiddleware(
aiGatewayHost: string | undefined,
fullDomain: string
): { headers: { customRequestHeaders: Record<string, string> } } {
return {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
}
export function buildAiGatewayTrustMiddlewares(): Record<string, any> {
const token = getAiGatewayTrustToken();
return {
[AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE]: {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: token,
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource"
}
}
},
[AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE]: {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: token,
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource"
}
}
}
};
}
export function buildAiGatewayClientIpMiddleware(): Record<string, any> | null {
const enabled =
config.getRawConfig().server.enable_ai_gateway_client_ip_header;
if (!enabled) {
return null;
}
return {
[AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME]: {
plugin: {
badger: {
disableForwardAuth: true,
realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER
}
}
}
};
}
/**
* Build the redirect (if ssl), main router, and single-server service for
* an AI-gateway-backed inference router. Identical between the public
* inference-resource and siteResource-inference cases, and between the OSS
* and private config generators - only the rule/tls/middleware chain
* differs, which callers resolve themselves beforehand.
*/
export function buildAiGatewayRouterAndService(params: {
routerName: string;
serviceName: string;
rule: string;
ssl: boolean | null;
tls: any;
priority: number;
routerMiddlewares: string[];
aiGatewayUrl: string;
redirectHttpsMiddlewareName: string;
}): { routers: Record<string, any>; services: Record<string, any> } {
const {
routerName,
serviceName,
rule,
ssl,
tls,
priority,
routerMiddlewares,
aiGatewayUrl,
redirectHttpsMiddlewareName
} = params;
const routers: Record<string, any> = {};
if (ssl) {
routers[`${routerName}-redirect`] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority
};
}
routers[routerName] = {
entryPoints: [
ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule,
priority,
...(ssl ? { tls } : {})
};
const services = {
[serviceName]: {
loadBalancer: {
servers: [{ url: aiGatewayUrl }]
}
}
};
return { routers, services };
}
+44
View File
@@ -0,0 +1,44 @@
import config from "@server/lib/config";
/**
* Build the Traefik `tls` block for a domain using the cert-resolver /
* wildcard-cert logic shared by both the OSS and private Traefik config
* generators (used whenever certs are obtained directly via ACME rather
* than through pangolin-dns).
*/
export function buildWildcardTls(params: {
fullDomain: string;
hasSubdomain: boolean;
domainCertResolver?: string | null;
preferWildcardCert?: boolean | null;
}): { certResolver: string | undefined; domains?: { main: string }[] } {
const { fullDomain, hasSubdomain, domainCertResolver, preferWildcardCert } =
params;
const domainParts = fullDomain.split(".");
let wildCard =
domainParts.length <= 2
? `*.${domainParts.join(".")}`
: `*.${domainParts.slice(1).join(".")}`;
if (!hasSubdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver = config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = domainCertResolver
? domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
preferWildcardCert !== undefined && preferWildcardCert !== null
? preferWildcardCert
: globalDefaultPreferWildcard;
return {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
}
+116 -525
View File
@@ -21,38 +21,33 @@ import {
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db"; import { resources, sites, Target, targets } from "@server/db";
import createPathRewriteMiddleware from "./middleware"; import { applyPathRewriteMiddleware } from "./middleware";
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils"; import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
import regionalCache from "@server/lib/cache"; import regionalCache from "@server/lib/cache";
import { TargetWithSite } from "./types";
import { buildWildcardTls } from "./certResolver";
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
import { import {
AI_GATEWAY_TRUST_HEADER, buildHttpLoadBalancerServers,
AI_GATEWAY_RESOURCE_TYPE_HEADER, buildStickySessionCookie,
AI_GATEWAY_CLIENT_IP_HEADER, buildTcpUdpLoadBalancerServers,
getAiGatewayTrustToken buildStickySessionIp
} from "@server/lib/aiGatewayTrust"; } from "./loadBalancer";
import { buildCustomHeadersMiddleware } from "./headersMiddleware";
import {
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
getAiGatewayHost,
buildAiGatewayTrustMiddlewares,
buildAiGatewayClientIpMiddleware,
buildAiGatewayHostHeaderMiddleware,
buildAiGatewayRouterAndService
} from "./aiGatewayMiddlewares";
const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger"; const badgerMiddlewareName = "badger";
// Define extended target type with site information
type TargetWithSite = Target & {
resourceId: number;
targetId: number;
ip: string | null;
method: string | null;
port: number | null;
internalPort: number | null;
enabled: boolean;
health: string | null;
site: {
siteId: number;
type: string;
subnet: string | null;
exitNodeId: number | null;
online: boolean;
};
};
export async function getTraefikConfig( export async function getTraefikConfig(
exitNodeId: number, exitNodeId: number,
siteTypes: string[], siteTypes: string[],
@@ -319,56 +314,12 @@ export async function getTraefikConfig(
config_output.http.services = {}; config_output.http.services = {};
} }
const domainParts = fullDomain.split("."); const tls = buildWildcardTls({
let wildCard; fullDomain,
if (domainParts.length <= 2) { hasSubdomain: !!resource.subdomain,
wildCard = `*.${domainParts.join(".")}`; domainCertResolver: resource.domainCertResolver,
} else { preferWildcardCert: resource.preferWildcardCert
wildCard = `*.${domainParts.slice(1).join(".")}`; });
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const domainCertResolver = resource.domainCertResolver;
const preferWildcardCert = resource.preferWildcardCert;
let resolverName: string | undefined;
let preferWildcard: boolean | undefined;
// Handle both letsencrypt & custom cases
if (domainCertResolver) {
resolverName = domainCertResolver.trim();
} else {
resolverName = globalDefaultResolver;
}
if (
preferWildcardCert !== undefined &&
preferWildcardCert !== null
) {
preferWildcard = preferWildcardCert;
} else {
preferWildcard = globalDefaultPreferWildcard;
}
const tls = {
certResolver: resolverName,
...(preferWildcard
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
@@ -379,134 +330,40 @@ export async function getTraefikConfig(
]; ];
// Handle path rewriting middleware // Handle path rewriting middleware
if ( applyPathRewriteMiddleware(
resource.rewritePath !== null && config_output,
resource.path !== null && resource.resourceId,
resource.pathMatchType && key,
resource.rewritePathType resource.path,
) { resource.pathMatchType,
// Create a unique middleware name resource.rewritePath,
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`; resource.rewritePathType,
routerMiddlewares
try { );
const rewriteResult = createPathRewriteMiddleware(
rewriteMiddlewareName,
resource.path,
resource.pathMatchType,
resource.rewritePath,
resource.rewritePathType
);
// Initialize middlewares object if it doesn't exist
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
// the middleware to the config
Object.assign(
config_output.http.middlewares,
rewriteResult.middlewares
);
// middlewares to the router middleware chain
if (rewriteResult.chain) {
// For chained middlewares (like stripPrefix + addPrefix)
routerMiddlewares.push(...rewriteResult.chain);
} else {
// Single middleware
routerMiddlewares.push(rewriteMiddlewareName);
}
// logger.debug(
// `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})`
// );
} catch (error) {
logger.error(
`Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}`
);
}
}
// Handle custom headers middleware // Handle custom headers middleware
if (resource.headers || resource.setHostHeader) { const customHeadersMiddleware = buildCustomHeadersMiddleware(
const headersObj: { [key: string]: string } = {}; resource.headers,
resource.setHostHeader,
if (resource.headers) { resource.resourceId
let headersArr: { name: string; value: string }[] = []; );
try { if (customHeadersMiddleware) {
headersArr = JSON.parse(resource.headers) as { if (!config_output.http.middlewares) {
name: string; config_output.http.middlewares = {};
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse headers for resource ${resource.resourceId}: ${e}`
);
}
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
}
if (resource.setHostHeader) {
headersObj["Host"] = resource.setHostHeader;
}
if (Object.keys(headersObj).length > 0) {
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[headersMiddlewareName] = {
headers: {
customRequestHeaders: headersObj
}
};
routerMiddlewares.push(headersMiddlewareName);
} }
config_output.http.middlewares[headersMiddlewareName] =
customHeadersMiddleware;
routerMiddlewares.push(headersMiddlewareName);
} }
// Build routing rules // Build routing rules
let rule = `Host(\`${fullDomain}\`)`; let rule = buildHostRule(fullDomain);
const priority = computeRoutePriority(
// priority logic resource.priority,
let priority: number; resource.path,
if (resource.priority && resource.priority != 100) { resource.pathMatchType
priority = resource.priority; );
} else { rule = appendPathMatch(rule, resource.path, resource.pathMatchType);
priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 10;
if (resource.pathMatchType === "exact") {
priority += 5;
} else if (resource.pathMatchType === "prefix") {
priority += 3;
} else if (resource.pathMatchType === "regex") {
priority += 2;
}
if (resource.path === "/") {
priority = 1; // lowest for catch-all
}
}
}
if (resource.path && resource.pathMatchType) {
// priority += 1;
// add path to rule based on match type
let path = resource.path;
// if the path doesn't start with a /, add it
if (!path.startsWith("/")) {
path = `/${path}`;
}
if (resource.pathMatchType === "exact") {
rule += ` && Path(\`${path}\`)`;
} else if (resource.pathMatchType === "prefix") {
rule += ` && PathPrefix(\`${path}\`)`;
} else if (resource.pathMatchType === "regex") {
rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex
}
}
config_output.http.routers![routerName] = { config_output.http.routers![routerName] = {
entryPoints: [ entryPoints: [
@@ -535,90 +392,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = { config_output.http.services![serviceName] = {
loadBalancer: { loadBalancer: {
servers: (() => { servers: buildHttpLoadBalancerServers(targets),
// Check if any sites are online
// THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK
// EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE
// RECEIVE BANDWIDTH ENDPOINT.
// TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some(
(target) => target.site.online
);
return (
targets
.filter((target) => {
if (!target.enabled) {
return false;
}
if (target.health == "unhealthy") {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (
!target.ip ||
!target.port ||
!target.method
) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.method ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
// filter out duplicates
.filter(
(v, i, a) =>
a.findIndex(
(t) => t && v && t.url === v.url
) === i
)
);
})(),
...(resource.stickySession ...(resource.stickySession
? { ? buildStickySessionCookie(resource.ssl)
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
: {}) : {})
} }
}; };
@@ -668,72 +444,13 @@ export async function getTraefikConfig(
config_output[protocol].services[serviceName] = { config_output[protocol].services[serviceName] = {
loadBalancer: { loadBalancer: {
servers: (() => { servers: buildTcpUdpLoadBalancerServers(targets),
// Check if any sites are online
const anySitesOnline = targets.some(
(target) => target.site.online
);
return targets
.filter((target) => {
if (!target.enabled) {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (!target.ip || !target.port) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
address: `${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};
}
});
})(),
...(resource.proxyProtocol && protocol == "tcp" ...(resource.proxyProtocol && protocol == "tcp"
? { ? {
serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues? serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues?
} }
: {}), : {}),
...(resource.stickySession ...(resource.stickySession ? buildStickySessionIp() : {})
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
} }
}; };
} }
@@ -747,64 +464,23 @@ export async function getTraefikConfig(
// recognize, so we pin the Host header to the gateway's own host // recognize, so we pin the Host header to the gateway's own host
// and smuggle the original resource host through in "p-host" // and smuggle the original resource host through in "p-host"
// instead. // instead.
let aiGatewayHost: string | undefined; const aiGatewayHost = getAiGatewayHost(aiGatewayUrl);
try {
aiGatewayHost = new URL(aiGatewayUrl).host;
} catch {
aiGatewayHost = undefined;
}
// The trust token is the same for every inference route on this exit
// node, so it's defined once here and attached to each router below
// instead of being duplicated into a per-resource middleware. Two
// variants exist (public resource vs. siteResource) so the resource
// type header lets the gateway know which kind of router the
// request came through without re-deriving it from resourceId.
const aiGatewayTrustMiddlewareNameResource =
"ai-gateway-trust-headers-resource";
const aiGatewayTrustMiddlewareNameSiteResource =
"ai-gateway-trust-headers-site-resource";
if (!config_output.http.middlewares) { if (!config_output.http.middlewares) {
config_output.http.middlewares = {}; config_output.http.middlewares = {};
} }
config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = { Object.assign(
headers: { config_output.http.middlewares,
customRequestHeaders: { buildAiGatewayTrustMiddlewares()
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(), );
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource"
}
}
};
config_output.http.middlewares[
aiGatewayTrustMiddlewareNameSiteResource
] = {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(),
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource"
}
}
};
// Opt-in: a Badger instance with forward auth disabled, used only const aiGatewayClientIpMiddleware = buildAiGatewayClientIpMiddleware();
// to stamp the resolved client IP into a dedicated header before const enableAiGatewayClientIpHeader = !!aiGatewayClientIpMiddleware;
// the request reaches whatever sits between Traefik and the AI if (aiGatewayClientIpMiddleware) {
// gateway. Only the site-resource router below needs this - it's Object.assign(
// the only path that resolves request identity from the client IP config_output.http.middlewares,
// (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the aiGatewayClientIpMiddleware
// 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 // Public inference resources: same TLS/cert-resolver handling as
@@ -822,95 +498,41 @@ export async function getTraefikConfig(
const routerName = `${irKey}-router`; const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`; const serviceName = `${irKey}-service`;
let rule: string; const rule = buildHostRule(fullDomain, ir.wildcard);
if (ir.wildcard && fullDomain.startsWith("*.")) {
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
const domainParts = fullDomain.split("."); const tls = buildWildcardTls({
let wildCard; fullDomain,
if (domainParts.length <= 2) { hasSubdomain: !!ir.subdomain,
wildCard = `*.${domainParts.join(".")}`; domainCertResolver: ir.domainCertResolver,
} else { preferWildcardCert: ir.preferWildcardCert
wildCard = `*.${domainParts.slice(1).join(".")}`; });
}
if (!ir.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = ir.domainCertResolver
? ir.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
ir.preferWildcardCert !== undefined &&
ir.preferWildcardCert !== null
? ir.preferWildcardCert
: globalDefaultPreferWildcard;
const tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
const irHeadersMiddlewareName = `${irKey}-headers-middleware`; const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
if (!config_output.http.middlewares) { config_output.http.middlewares[irHeadersMiddlewareName] =
config_output.http.middlewares = {}; buildAiGatewayHostHeaderMiddleware(aiGatewayHost, fullDomain);
}
config_output.http.middlewares[irHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [ const routerMiddlewares = [
badgerMiddlewareName, badgerMiddlewareName,
aiGatewayTrustMiddlewareNameResource, AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
irHeadersMiddlewareName, irHeadersMiddlewareName,
...additionalMiddlewares ...additionalMiddlewares
]; ];
if (ir.ssl) { const { routers, services } = buildAiGatewayRouterAndService({
config_output.http.routers[routerName + "-redirect"] = { routerName,
entryPoints: [ serviceName,
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 100
};
}
config_output.http.routers[routerName] = {
entryPoints: [
ir.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule, rule,
ssl: ir.ssl,
tls,
priority: 100, priority: 100,
...(ir.ssl ? { tls } : {}) routerMiddlewares,
}; aiGatewayUrl,
redirectHttpsMiddlewareName
config_output.http.services[serviceName] = { });
loadBalancer: { Object.assign(config_output.http.routers, routers);
servers: [{ url: aiGatewayUrl }] Object.assign(config_output.http.services, services);
}
};
} }
// Private (siteResource) inference resources: routed by their alias // Private (siteResource) inference resources: routed by their alias
@@ -946,80 +568,49 @@ export async function getTraefikConfig(
const srKey = `inference-sr${sr.siteResourceId}`; const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`; const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`; const serviceName = `${srKey}-service`;
const rule = `Host(\`${fullDomain}\`) && ClientIP(${exitNode.address})`; // restrict to coming from the exit node ip range that the client is connected to const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
const domainParts = fullDomain.split("."); // siteResource aliases don't have a per-domain cert resolver
const wildCard = // stored, so always fall back to the global defaults.
domainParts.length <= 2 const tls = buildWildcardTls({
? `*.${domainParts.join(".")}` fullDomain,
: `*.${domainParts.slice(1).join(".")}`; hasSubdomain: true
});
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
const srHeadersMiddlewareName = `${srKey}-headers-middleware`; const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
if (!config_output.http.middlewares) { if (!config_output.http.middlewares) {
config_output.http.middlewares = {}; config_output.http.middlewares = {};
} }
config_output.http.middlewares[srHeadersMiddlewareName] = { config_output.http.middlewares[srHeadersMiddlewareName] =
headers: { buildAiGatewayHostHeaderMiddleware(
customRequestHeaders: { aiGatewayHost,
...(aiGatewayHost ? { Host: aiGatewayHost } : {}), fullDomain
"p-host": fullDomain );
}
}
};
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [ const routerMiddlewares = [
...(enableAiGatewayClientIpHeader ...(enableAiGatewayClientIpHeader
? [aiGatewayClientIpMiddlewareName] ? [AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME]
: []), : []),
aiGatewayTrustMiddlewareNameSiteResource, AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
srHeadersMiddlewareName, srHeadersMiddlewareName,
...additionalMiddlewares ...additionalMiddlewares
]; ];
if (sr.ssl) { const { routers, services } = buildAiGatewayRouterAndService({
config_output.http.routers[routerName + "-redirect"] = { routerName,
entryPoints: [ serviceName,
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
};
}
config_output.http.routers[routerName] = {
entryPoints: [
sr.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule, rule,
ssl: sr.ssl,
tls,
priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
...(sr.ssl ? { tls } : {}) routerMiddlewares,
}; aiGatewayUrl,
redirectHttpsMiddlewareName
config_output.http.services[serviceName] = { });
loadBalancer: { Object.assign(config_output.http.routers, routers);
servers: [{ url: aiGatewayUrl }] Object.assign(config_output.http.services, services);
}
};
} }
} }
} }
+46
View File
@@ -0,0 +1,46 @@
import logger from "@server/logger";
/**
* Build the customRequestHeaders middleware definition for a resource's
* custom headers + setHostHeader config. Returns null when there are no
* headers to set, so the caller can skip attaching the middleware.
*/
export function buildCustomHeadersMiddleware(
headers: string | null | undefined,
setHostHeader: string | null | undefined,
resourceId: number
): { headers: { customRequestHeaders: { [key: string]: string } } } | null {
const headersObj: { [key: string]: string } = {};
if (headers) {
let headersArr: { name: string; value: string }[] = [];
try {
headersArr = JSON.parse(headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse headers for resource ${resourceId}: ${e}`
);
}
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
}
if (setHostHeader) {
headersObj["Host"] = setHostHeader;
}
if (Object.keys(headersObj).length === 0) {
return null;
}
return {
headers: {
customRequestHeaders: headersObj
}
};
}
+134
View File
@@ -0,0 +1,134 @@
import { TargetWithSite } from "./types";
/**
* Build the loadBalancer.servers list for an HTTP-mode resource, preferring
* currently-online sites but falling back to all enabled/healthy targets if
* none are online yet (so there's still some feedback before sites report
* back over the receive-bandwidth endpoint).
*/
export function buildHttpLoadBalancerServers(targets: TargetWithSite[]) {
const anySitesOnline = targets.some((target) => target.site.online);
return targets
.filter((target) => {
if (!target.enabled) {
return false;
}
if (target.health == "unhealthy") {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (!target.ip || !target.port || !target.method) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.method ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip = target.site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
.filter(
(v, i, a) => a.findIndex((t) => t && v && t.url === v.url) === i
);
}
export function buildStickySessionCookie(ssl: boolean | null) {
return {
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: ssl,
httpOnly: true
}
}
};
}
/**
* Build the loadBalancer.servers list for a TCP/UDP-mode resource.
*/
export function buildTcpUdpLoadBalancerServers(targets: TargetWithSite[]) {
const anySitesOnline = targets.some((target) => target.site.online);
return targets
.filter((target) => {
if (!target.enabled) {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (!target.ip || !target.port) {
return false;
}
} else if (target.site.type === "newt") {
if (!target.internalPort || !target.site.subnet) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
address: `${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip = target.site.subnet!.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};
}
});
}
export function buildStickySessionIp() {
return {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
};
}
+59
View File
@@ -1,5 +1,64 @@
import logger from "@server/logger"; import logger from "@server/logger";
/**
* Create (if configured) and attach a path-rewrite middleware for a
* resource, mutating both config_output.http.middlewares and the
* router's middleware chain. Shared by the OSS and private Traefik config
* generators, which apply it identically.
*/
export function applyPathRewriteMiddleware(
config_output: any,
resourceId: number,
key: string,
path: string | null,
pathMatchType: string | null,
rewritePath: string | null,
rewritePathType: string | null,
routerMiddlewares: string[]
) {
if (
rewritePath === null ||
path === null ||
!pathMatchType ||
!rewritePathType
) {
return;
}
const rewriteMiddlewareName = `rewrite-r${resourceId}-${key}`;
try {
const rewriteResult = createPathRewriteMiddleware(
rewriteMiddlewareName,
path,
pathMatchType,
rewritePath,
rewritePathType
);
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
Object.assign(
config_output.http.middlewares,
rewriteResult.middlewares
);
if (rewriteResult.chain) {
// For chained middlewares (like stripPrefix + addPrefix)
routerMiddlewares.push(...rewriteResult.chain);
} else {
// Single middleware
routerMiddlewares.push(rewriteMiddlewareName);
}
} catch (error) {
logger.error(
`Failed to create path rewrite middleware for resource ${resourceId}: ${error}`
);
}
}
export default function createPathRewriteMiddleware( export default function createPathRewriteMiddleware(
middlewareName: string, middlewareName: string,
path: string, path: string,
+71
View File
@@ -0,0 +1,71 @@
/**
* Build the Host()/HostRegexp() Traefik rule for a resource's domain.
* Wildcard resources match any single subdomain via HostRegexp.
*/
export function buildHostRule(
fullDomain: string,
wildcard?: boolean | null
): string {
if (wildcard && fullDomain.startsWith("*.")) {
// Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`)
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
return `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
}
return `Host(\`${fullDomain}\`)`;
}
/**
* Append a path-matching clause to a Traefik rule based on the resource's
* configured path and pathMatchType.
*/
export function appendPathMatch(
rule: string,
path: string | null | undefined,
pathMatchType: string | null | undefined
): string {
if (!path || !pathMatchType) return rule;
let p = path;
if (!p.startsWith("/")) {
p = `/${p}`;
}
if (pathMatchType === "exact") {
return `${rule} && Path(\`${p}\`)`;
} else if (pathMatchType === "prefix") {
return `${rule} && PathPrefix(\`${p}\`)`;
} else if (pathMatchType === "regex") {
return `${rule} && PathRegexp(\`${path}\`)`; // this is the raw path because it's a regex
}
return rule;
}
/**
* Compute the router priority for a resource, favoring an explicit override
* and otherwise deriving it from the path match specificity.
*/
export function computeRoutePriority(
priority: number | null | undefined,
path: string | null | undefined,
pathMatchType: string | null | undefined
): number {
if (priority && priority != 100) {
return priority;
}
let p = 100;
if (path && pathMatchType) {
p += 10;
if (pathMatchType === "exact") {
p += 5;
} else if (pathMatchType === "prefix") {
p += 3;
} else if (pathMatchType === "regex") {
p += 2;
}
if (path === "/") {
p = 1; // lowest for catch-all
}
}
return p;
}
+21
View File
@@ -0,0 +1,21 @@
import { Target } from "@server/db";
// Extended target type with site information, shared between the OSS and
// private getTraefikConfig implementations.
export type TargetWithSite = Target & {
resourceId: number;
targetId: number;
ip: string | null;
method: string | null;
port: number | null;
internalPort: number | null;
enabled: boolean;
health: string | null;
site: {
siteId: number;
type: string;
subnet: string | null;
exitNodeId: number | null;
online: boolean;
};
};
+136 -579
View File
@@ -40,7 +40,6 @@ import {
sites, sites,
siteNetworks, siteNetworks,
siteResources, siteResources,
Target,
targets targets
} from "@server/db"; } from "@server/db";
import { import {
@@ -49,19 +48,37 @@ import {
validatePathRewriteConfig validatePathRewriteConfig
} from "@server/lib/traefik/utils"; } from "@server/lib/traefik/utils";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import createPathRewriteMiddleware from "@server/lib/traefik/middleware"; import { applyPathRewriteMiddleware } from "@server/lib/traefik/middleware";
import { import {
CertificateResult, CertificateResult,
getValidCertificatesForDomains getValidCertificatesForDomains
} from "@server/lib/certificates"; } from "@server/lib/certificates";
import { build } from "@server/build"; import { build } from "@server/build";
import regionalCache from "#private/lib/cache"; import regionalCache from "#private/lib/cache";
import { TargetWithSite } from "@server/lib/traefik/types";
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
import { import {
AI_GATEWAY_TRUST_HEADER, buildHostRule,
AI_GATEWAY_RESOURCE_TYPE_HEADER, appendPathMatch,
AI_GATEWAY_CLIENT_IP_HEADER, computeRoutePriority
getAiGatewayTrustToken } from "@server/lib/traefik/rule";
} from "@server/lib/aiGatewayTrust"; import {
buildHttpLoadBalancerServers,
buildStickySessionCookie,
buildTcpUdpLoadBalancerServers,
buildStickySessionIp
} from "@server/lib/traefik/loadBalancer";
import { buildCustomHeadersMiddleware } from "@server/lib/traefik/headersMiddleware";
import {
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
getAiGatewayHost,
buildAiGatewayTrustMiddlewares,
buildAiGatewayClientIpMiddleware,
buildAiGatewayHostHeaderMiddleware,
buildAiGatewayRouterAndService
} from "@server/lib/traefik/aiGatewayMiddlewares";
const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectHttpsMiddlewareName = "redirect-to-https";
const redirectToRootMiddlewareName = "redirect-to-root"; const redirectToRootMiddlewareName = "redirect-to-root";
@@ -69,25 +86,6 @@ const badgerMiddlewareName = "badger";
const landingRateLimitMiddlewareName = "landing-ratelimit"; const landingRateLimitMiddlewareName = "landing-ratelimit";
const bgRateLimitMiddlewareName = "bg-ratelimit"; const bgRateLimitMiddlewareName = "bg-ratelimit";
// Define extended target type with site information
type TargetWithSite = Target & {
resourceId: number;
targetId: number;
ip: string | null;
method: string | null;
port: number | null;
internalPort: number | null;
enabled: boolean;
health: string | null;
site: {
siteId: number;
type: string;
subnet: string | null;
exitNodeId: number | null;
online: boolean;
};
};
export async function getTraefikConfig( export async function getTraefikConfig(
exitNodeId: number, exitNodeId: number,
siteTypes: string[], siteTypes: string[],
@@ -580,91 +578,23 @@ export async function getTraefikConfig(
...additionalMiddlewares ...additionalMiddlewares
]; ];
let rule: string; let rule: string = buildHostRule(fullDomain, resource.wildcard);
if (resource.wildcard && fullDomain.startsWith("*.")) {
// Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`)
const escaped = fullDomain
.slice(2) // remove leading "*."
.replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
// priority logic const priority = computeRoutePriority(
let priority: number; resource.priority,
if (resource.priority && resource.priority != 100) { resource.path,
priority = resource.priority; resource.pathMatchType
} else { );
priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 10;
if (resource.pathMatchType === "exact") {
priority += 5;
} else if (resource.pathMatchType === "prefix") {
priority += 3;
} else if (resource.pathMatchType === "regex") {
priority += 2;
}
if (resource.path === "/") {
priority = 1; // lowest for catch-all
}
}
}
let tls = {}; let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split("."); tls = buildWildcardTls({
let wildCard; fullDomain,
if (domainParts.length <= 2) { hasSubdomain: !!resource.subdomain,
wildCard = `*.${domainParts.join(".")}`; domainCertResolver: resource.domainCertResolver,
} else { preferWildcardCert:
wildCard = `*.${domainParts.slice(1).join(".")}`; resource.preferWildcardCert || resource.wildcard
} });
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const domainCertResolver = resource.domainCertResolver;
const preferWildcardCert =
resource.preferWildcardCert || resource.wildcard;
let resolverName: string | undefined;
let preferWildcard: boolean | undefined;
// Handle both letsencrypt & custom cases
if (domainCertResolver) {
resolverName = domainCertResolver.trim();
} else {
resolverName = globalDefaultResolver;
}
if (
preferWildcardCert !== undefined &&
preferWildcardCert !== null
) {
preferWildcard = preferWildcardCert;
} else {
preferWildcard = globalDefaultPreferWildcard;
}
tls = {
certResolver: resolverName,
...(preferWildcard
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
} else { } else {
// find a cert that matches the full domain, if not continue // find a cert that matches the full domain, if not continue
const matchingCert = validCerts.find( const matchingCert = validCerts.find(
@@ -803,111 +733,32 @@ export async function getTraefikConfig(
} }
// Handle path rewriting middleware // Handle path rewriting middleware
if ( applyPathRewriteMiddleware(
resource.rewritePath !== null && config_output,
resource.path !== null && resource.resourceId,
resource.pathMatchType && key,
resource.rewritePathType resource.path,
) { resource.pathMatchType,
// Create a unique middleware name resource.rewritePath,
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`; resource.rewritePathType,
routerMiddlewares
);
try { const customHeadersMiddleware = buildCustomHeadersMiddleware(
const rewriteResult = createPathRewriteMiddleware( resource.headers,
rewriteMiddlewareName, resource.setHostHeader,
resource.path, resource.resourceId
resource.pathMatchType, );
resource.rewritePath, if (customHeadersMiddleware) {
resource.rewritePathType if (!config_output.http.middlewares) {
); config_output.http.middlewares = {};
// Initialize middlewares object if it doesn't exist
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
// the middleware to the config
Object.assign(
config_output.http.middlewares,
rewriteResult.middlewares
);
// middlewares to the router middleware chain
if (rewriteResult.chain) {
// For chained middlewares (like stripPrefix + addPrefix)
routerMiddlewares.push(...rewriteResult.chain);
} else {
// Single middleware
routerMiddlewares.push(rewriteMiddlewareName);
}
// logger.debug(
// `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})`
// );
} catch (error) {
logger.error(
`Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}`
);
} }
config_output.http.middlewares[headersMiddlewareName] =
customHeadersMiddleware;
routerMiddlewares.push(headersMiddlewareName);
} }
if (resource.headers || resource.setHostHeader) { rule = appendPathMatch(rule, resource.path, resource.pathMatchType);
// if there are headers, parse them into an object
const headersObj: { [key: string]: string } = {};
if (resource.headers) {
let headersArr: { name: string; value: string }[] = [];
try {
headersArr = JSON.parse(resource.headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse headers for resource ${resource.resourceId}: ${e}`
);
}
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
}
if (resource.setHostHeader) {
headersObj["Host"] = resource.setHostHeader;
}
// check if the object is not empty
if (Object.keys(headersObj).length > 0) {
// Add the headers middleware
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[headersMiddlewareName] = {
headers: {
customRequestHeaders: headersObj
}
};
routerMiddlewares.push(headersMiddlewareName);
}
}
if (resource.path && resource.pathMatchType) {
//priority += 1;
// add path to rule based on match type
let path = resource.path;
// if the path doesn't start with a /, add it
if (!path.startsWith("/")) {
path = `/${path}`;
}
if (resource.pathMatchType === "exact") {
rule += ` && Path(\`${path}\`)`;
} else if (resource.pathMatchType === "prefix") {
rule += ` && PathPrefix(\`${path}\`)`;
} else if (resource.pathMatchType === "regex") {
rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex
}
}
config_output.http.routers![routerName] = { config_output.http.routers![routerName] = {
entryPoints: [ entryPoints: [
@@ -924,90 +775,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = { config_output.http.services![serviceName] = {
loadBalancer: { loadBalancer: {
servers: (() => { servers: buildHttpLoadBalancerServers(targets),
// Check if any sites are online
// THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK
// EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE
// RECEIVE BANDWIDTH ENDPOINT.
// TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some(
(target) => target.site.online
);
return (
targets
.filter((target) => {
if (!target.enabled) {
return false;
}
if (target.health == "unhealthy") {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (
!target.ip ||
!target.port ||
!target.method
) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.method ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
// filter out duplicates
.filter(
(v, i, a) =>
a.findIndex(
(t) => t && v && t.url === v.url
) === i
)
);
})(),
...(resource.stickySession ...(resource.stickySession
? { ? buildStickySessionCookie(resource.ssl)
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
: {}) : {})
} }
}; };
@@ -1057,72 +827,13 @@ export async function getTraefikConfig(
config_output[protocol].services[serviceName] = { config_output[protocol].services[serviceName] = {
loadBalancer: { loadBalancer: {
servers: (() => { servers: buildTcpUdpLoadBalancerServers(targets),
// Check if any sites are online
const anySitesOnline = targets.some(
(target) => target.site.online
);
return targets
.filter((target) => {
if (!target.enabled) {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (!target.ip || !target.port) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
address: `${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};
}
});
})(),
...(resource.proxyProtocol && protocol == "tcp" // proxy protocol only works for tcp ...(resource.proxyProtocol && protocol == "tcp" // proxy protocol only works for tcp
? { ? {
serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues? serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues?
} }
: {}), : {}),
...(resource.stickySession ...(resource.stickySession ? buildStickySessionIp() : {})
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
} }
}; };
} }
@@ -1153,34 +864,12 @@ export async function getTraefikConfig(
// Build TLS config // Build TLS config
let tls = {}; let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split("."); tls = buildWildcardTls({
let wildCard: string; fullDomain,
if (domainParts.length <= 2) { hasSubdomain: !!bgResource.subdomain,
wildCard = `*.${domainParts.join(".")}`; domainCertResolver: bgResource.domainCertResolver,
} else { preferWildcardCert: bgResource.preferWildcardCert
wildCard = `*.${domainParts.slice(1).join(".")}`; });
}
if (!bgResource.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = bgResource.domainCertResolver
? bgResource.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
bgResource.preferWildcardCert !== undefined &&
bgResource.preferWildcardCert !== null
? bgResource.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else { } else {
const matchingCert = validCerts.find( const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain (cert) => cert.queriedDomain === fullDomain
@@ -1483,23 +1172,12 @@ export async function getTraefikConfig(
// Determine TLS / cert-resolver configuration // Determine TLS / cert-resolver configuration
let tls: any = {}; let tls: any = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split("."); // siteResource aliases don't have a per-domain cert resolver
const wildCard = // stored, so always fall back to the global defaults.
domainParts.length <= 2 tls = buildWildcardTls({
? `*.${domainParts.join(".")}` fullDomain,
: `*.${domainParts.slice(1).join(".")}`; hasSubdomain: true
});
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
} else { } else {
// pangolin-dns: only add route if we already have a valid cert // pangolin-dns: only add route if we already have a valid cert
const matchingCert = validCerts.find( const matchingCert = validCerts.find(
@@ -1542,12 +1220,7 @@ export async function getTraefikConfig(
// recognize, so we pin the Host header to the gateway's own host // recognize, so we pin the Host header to the gateway's own host
// and smuggle the original resource host through in "p-host" // and smuggle the original resource host through in "p-host"
// instead (same pattern as the maintenance-page routes above). // instead (same pattern as the maintenance-page routes above).
let aiGatewayHost: string | undefined; const aiGatewayHost = getAiGatewayHost(aiGatewayUrl);
try {
aiGatewayHost = new URL(aiGatewayUrl).host;
} catch {
aiGatewayHost = undefined;
}
// The p-host smuggling above is only necessary when the AI gateway // The p-host smuggling above is only necessary when the AI gateway
// is overridden to a different host than the resource's own. In the // is overridden to a different host than the resource's own. In the
@@ -1556,54 +1229,18 @@ export async function getTraefikConfig(
const aiGatewayOverride = const aiGatewayOverride =
config.getRawConfig().server.ai_gateway_override; config.getRawConfig().server.ai_gateway_override;
// The trust token is the same for every inference route on this exit Object.assign(
// node, so it's defined once here and attached to each router below config_output.http.middlewares,
// instead of being duplicated into a per-resource middleware. Two buildAiGatewayTrustMiddlewares()
// variants exist (public resource vs. siteResource) so the resource );
// type header lets the gateway know which kind of router the
// request came through without re-deriving it from resourceId.
const aiGatewayTrustMiddlewareNameResource =
"ai-gateway-trust-headers-resource";
const aiGatewayTrustMiddlewareNameSiteResource =
"ai-gateway-trust-headers-site-resource";
config_output.http.middlewares[aiGatewayTrustMiddlewareNameResource] = {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(),
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource"
}
}
};
config_output.http.middlewares[
aiGatewayTrustMiddlewareNameSiteResource
] = {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: getAiGatewayTrustToken(),
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource"
}
}
};
// Opt-in: a Badger instance with forward auth disabled, used only const aiGatewayClientIpMiddleware = buildAiGatewayClientIpMiddleware();
// to stamp the resolved client IP into a dedicated header before const enableAiGatewayClientIpHeader = !!aiGatewayClientIpMiddleware;
// the request reaches whatever sits between Traefik and the AI if (aiGatewayClientIpMiddleware) {
// gateway. Only the site-resource router below needs this - it's Object.assign(
// the only path that resolves request identity from the client IP config_output.http.middlewares,
// (see resolveRequestUser in aiGateway/pipeline.ts) - and it's the aiGatewayClientIpMiddleware
// 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 // Public inference resources: same TLS/cert-resolver handling as
@@ -1621,44 +1258,16 @@ export async function getTraefikConfig(
const routerName = `${irKey}-router`; const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`; const serviceName = `${irKey}-service`;
let rule: string; const rule = buildHostRule(fullDomain, ir.wildcard);
if (ir.wildcard && fullDomain.startsWith("*.")) {
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
let tls: any = {}; let tls: any = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split("."); tls = buildWildcardTls({
let wildCard; fullDomain,
if (domainParts.length <= 2) { hasSubdomain: !!ir.subdomain,
wildCard = `*.${domainParts.join(".")}`; domainCertResolver: ir.domainCertResolver,
} else { preferWildcardCert: ir.preferWildcardCert
wildCard = `*.${domainParts.slice(1).join(".")}`; });
}
if (!ir.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = ir.domainCertResolver
? ir.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
ir.preferWildcardCert !== undefined &&
ir.preferWildcardCert !== null
? ir.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else { } else {
const matchingCert = validCerts.find( const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain (cert) => cert.queriedDomain === fullDomain
@@ -1675,54 +1284,34 @@ export async function getTraefikConfig(
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [ const routerMiddlewares = [
badgerMiddlewareName, badgerMiddlewareName,
aiGatewayTrustMiddlewareNameResource AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE
]; ];
if (aiGatewayOverride) { if (aiGatewayOverride) {
const irHeadersMiddlewareName = `${irKey}-headers-middleware`; const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
config_output.http.middlewares[irHeadersMiddlewareName] = { config_output.http.middlewares[irHeadersMiddlewareName] =
headers: { buildAiGatewayHostHeaderMiddleware(
customRequestHeaders: { aiGatewayHost,
...(aiGatewayHost ? { Host: aiGatewayHost } : {}), fullDomain
"p-host": fullDomain );
}
}
};
routerMiddlewares.push(irHeadersMiddlewareName); routerMiddlewares.push(irHeadersMiddlewareName);
} }
routerMiddlewares.push(...additionalMiddlewares); routerMiddlewares.push(...additionalMiddlewares);
if (ir.ssl) { const { routers, services } = buildAiGatewayRouterAndService({
config_output.http.routers[routerName + "-redirect"] = { routerName,
entryPoints: [ serviceName,
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 100
};
}
config_output.http.routers[routerName] = {
entryPoints: [
ir.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule, rule,
ssl: ir.ssl,
tls,
priority: 100, priority: 100,
...(ir.ssl ? { tls } : {}) routerMiddlewares,
}; aiGatewayUrl,
redirectHttpsMiddlewareName
config_output.http.services[serviceName] = { });
loadBalancer: { Object.assign(config_output.http.routers, routers);
servers: [{ url: aiGatewayUrl }] Object.assign(config_output.http.services, services);
}
};
} }
if (exitNode) { if (exitNode) {
@@ -1749,23 +1338,13 @@ export async function getTraefikConfig(
if ( if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) { ) {
const domainParts = fullDomain.split("."); // siteResource aliases don't have a per-domain cert
const wildCard = // resolver stored, so always fall back to the global
domainParts.length <= 2 // defaults.
? `*.${domainParts.join(".")}` tls = buildWildcardTls({
: `*.${domainParts.slice(1).join(".")}`; fullDomain,
hasSubdomain: true
const globalDefaultResolver = });
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
tls = {
certResolver: globalDefaultResolver,
...(globalDefaultPreferWildcard
? { domains: [{ main: wildCard }] }
: {})
};
} else { } else {
const matchingCert = validCerts.find( const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain (cert) => cert.queriedDomain === fullDomain
@@ -1782,58 +1361,36 @@ export async function getTraefikConfig(
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares: string[] = [ const routerMiddlewares: string[] = [
...(enableAiGatewayClientIpHeader ...(enableAiGatewayClientIpHeader
? [aiGatewayClientIpMiddlewareName] ? [AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME]
: []), : []),
aiGatewayTrustMiddlewareNameSiteResource AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE
]; ];
if (aiGatewayOverride) { if (aiGatewayOverride) {
const srHeadersMiddlewareName = `${srKey}-headers-middleware`; const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
config_output.http.middlewares[srHeadersMiddlewareName] = { config_output.http.middlewares[srHeadersMiddlewareName] =
headers: { buildAiGatewayHostHeaderMiddleware(
customRequestHeaders: { aiGatewayHost,
...(aiGatewayHost fullDomain
? { Host: aiGatewayHost } );
: {}),
"p-host": fullDomain
}
}
};
routerMiddlewares.push(srHeadersMiddlewareName); routerMiddlewares.push(srHeadersMiddlewareName);
} }
routerMiddlewares.push(...additionalMiddlewares); routerMiddlewares.push(...additionalMiddlewares);
if (sr.ssl) { const { routers, services } = buildAiGatewayRouterAndService({
config_output.http.routers[routerName + "-redirect"] = { routerName,
entryPoints: [ serviceName,
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority: 200 // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
};
}
config_output.http.routers[routerName] = {
entryPoints: [
sr.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule, rule,
ssl: sr.ssl,
tls,
priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first. priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
...(sr.ssl ? { tls } : {}) routerMiddlewares,
}; aiGatewayUrl,
redirectHttpsMiddlewareName
config_output.http.services[serviceName] = { });
loadBalancer: { Object.assign(config_output.http.routers, routers);
servers: [{ url: aiGatewayUrl }] Object.assign(config_output.http.services, services);
}
};
} }
} }
} }