Merge upstream/dev into feature-response-headers

Resolve conflicts against upstream's refactors:

- server/db/sqlite/schema/schema.ts: adopt upstream's reindented
  sqliteTable(name, cols, indexes) form for sites/resources, re-applying
  the headers -> requestHeaders/responseHeaders split. Kept in sync with
  the Postgres schema.
- server/lib/traefik/headersMiddleware.ts: extend upstream's extracted
  buildCustomHeadersMiddleware helper to take requestHeaders and
  responseHeaders and emit both customRequestHeaders and
  customResponseHeaders.
- server/lib/traefik/getTraefikConfig.ts and
  server/private/lib/traefik/getTraefikConfig.ts: keep upstream's helper
  extraction and appendPathMatch refactor, dropping the superseded inline
  blocks.

Also carry the feature forward onto code that moved upstream:

- The resource settings UI moved from resources/proxy/[niceId]/proxy to
  resources/public/[niceId]/http, which dropped this branch's changes in
  the previous merge. Re-add the request/response header inputs there and
  rename the vestigial headers field on the tcp page.
- messages/da-DK.json is new upstream and still had the old customHeaders
  key; rename it in line with the other locales.

Per the contributing docs, versioned migrations are intentionally omitted
so maintainers can write them at release time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Julian van der Horst
2026-09-01 13:40:49 +02:00
co-authored by Claude Opus 5
694 changed files with 77422 additions and 12896 deletions
+32 -3
View File
@@ -8,7 +8,7 @@ import { db, exitNodes } from "@server/db";
import { eq } from "drizzle-orm";
import { getCurrentExitNodeId } from "@server/lib/exitNodes";
import { getTraefikConfig } from "#dynamic/lib/traefik";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
@@ -516,6 +516,11 @@ export class TraefikConfigManager {
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
const aiGatewayUrl =
config.getRawConfig().server.ai_gateway_override ||
`http://${maintenanceHost}:${
config.getRawConfig().server.ai_gateway_port
}`;
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
traefikConfig = await getTraefikConfig(
@@ -528,7 +533,8 @@ export class TraefikConfigManager {
? false
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
pangolinUIUrl, // generate maintenance pages on cloud and hybrid
pangolinUIUrl // generate browser gateway targets on cloud and hybrid
pangolinUIUrl, // generate browser gateway targets on cloud and hybrid
aiGatewayUrl
);
const domains = new Set<string>();
@@ -599,7 +605,30 @@ export class TraefikConfigManager {
resourceSessionRequestParam:
config.getRawConfig().server
.resource_session_request_param
.resource_session_request_param,
remoteUserIdHeader:
config.getRawConfig().server.remote_headers
.user_id,
remoteVirtualApiKeyIdHeader:
config.getRawConfig().server.remote_headers
.virtual_api_key_id,
remoteUserHeader:
config.getRawConfig().server.remote_headers
.user,
remoteEmailHeader:
config.getRawConfig().server.remote_headers
.email,
remoteNameHeader:
config.getRawConfig().server.remote_headers
.name,
remoteRoleHeader:
config.getRawConfig().server.remote_headers.role
}
}
};
+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 };
}
+399
View File
@@ -0,0 +1,399 @@
import config from "@server/lib/config";
import { sanitize } from "./utils";
export type BrowserGatewayResourceRow = {
resourceId: number;
resourceName: string | null;
mode: string;
fullDomain: string | null;
ssl: boolean | null;
subdomain: string | null;
domainId: string | null;
enabled: boolean | null;
wildcard: boolean | null;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
maintenanceModeEnabled: boolean | null;
maintenanceModeType: string | null;
maintenanceTitle: string | null;
maintenanceMessage: string | null;
maintenanceEstimatedTime: string | null;
targetId: number;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
// Cloud-only namespace field - absent on OSS rows, so the namespace
// filter below naturally no-ops there.
domainNamespaceId?: unknown;
};
export type BrowserGatewayResourceEntry = {
resourceId: number;
name: string;
fullDomain: string | null;
ssl: boolean | null;
subdomain: string | null;
domainId: string | null;
enabled: boolean | null;
wildcard: boolean | null;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
maintenanceModeEnabled: boolean | null;
maintenanceModeType: string | null;
maintenanceTitle: string | null;
maintenanceMessage: string | null;
maintenanceEstimatedTime: string | null;
targets: {
targetId: number;
bgType: string;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
}[];
};
/**
* Group the raw resource/target/site rows into per-resource browser-gateway
* entries (SSH/VNC/RDP-mode resources served through the browser gateway
* web UI instead of a real backend target).
*/
export function buildBrowserGatewayResourcesMap(
rows: BrowserGatewayResourceRow[],
filterOutNamespaceDomains: boolean
): Map<number, BrowserGatewayResourceEntry> {
const map = new Map<number, BrowserGatewayResourceEntry>();
for (const row of rows) {
if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
continue;
}
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
if (!map.has(row.resourceId)) {
map.set(row.resourceId, {
resourceId: row.resourceId,
name: sanitize(row.resourceName ?? undefined) || "",
fullDomain: row.fullDomain,
ssl: row.ssl,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
wildcard: row.wildcard,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
maintenanceModeEnabled: row.maintenanceModeEnabled,
maintenanceModeType: row.maintenanceModeType,
maintenanceTitle: row.maintenanceTitle,
maintenanceMessage: row.maintenanceMessage,
maintenanceEstimatedTime: row.maintenanceEstimatedTime,
targets: []
});
}
map.get(row.resourceId)!.targets.push({
targetId: row.targetId,
bgType: row.mode,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet
});
}
return map;
}
/**
* Build the Traefik routers/services for browser-gateway resources
* (SSH/VNC/RDP served via a browser-based client instead of a raw target),
* mutating config_output. TLS/cert-resolver handling differs between the
* OSS (always resolve directly) and private (pangolin-dns aware) config
* generators, so callers resolve that themselves via resolveTls - returning
* null skips the resource (no valid cert available yet).
*/
export function buildBrowserGatewayConfig(params: {
config_output: any;
browserGatewayResourcesMap: Map<number, BrowserGatewayResourceEntry>;
browserGatewayUiUrl: string;
maintenancePageUiUrl: string | null;
badgerMiddlewareName: string;
redirectHttpsMiddlewareName: string;
resolveTls: (args: {
fullDomain: string;
hasSubdomain: boolean;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
}) => any | null;
}): void {
const {
config_output,
browserGatewayResourcesMap,
browserGatewayUiUrl,
maintenancePageUiUrl,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls
} = params;
const bgRateLimitMiddlewareName = "bg-ratelimit";
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
if (!config_output.http.middlewares[bgRateLimitMiddlewareName]) {
const traefikRateLimit = config.getRawConfig().traefik.rate_limit;
config_output.http.middlewares[bgRateLimitMiddlewareName] = {
rateLimit: {
average: traefikRateLimit.average,
burst: traefikRateLimit.burst
}
};
}
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
if (!bgResource.enabled) continue;
if (!bgResource.domainId) continue;
if (!bgResource.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = bgResource.fullDomain;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
bgRateLimitMiddlewareName,
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
// Build TLS config
const tls = resolveTls({
fullDomain,
hasSubdomain: !!bgResource.subdomain,
domainCertResolver: bgResource.domainCertResolver,
preferWildcardCert: bgResource.preferWildcardCert
});
if (tls === null) {
continue;
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
config_output.http.routers![redirectRouterName] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100
};
}
// Collect online sites for this resource (for any type)
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
// Maintenance page logic for browser gateway resources
let showBgMaintenancePage = false;
if (bgResource.maintenanceModeEnabled) {
if (bgResource.maintenanceModeType === "forced") {
showBgMaintenancePage = true;
} else if (bgResource.maintenanceModeType === "automatic") {
showBgMaintenancePage = !anySiteOnline;
}
}
if (showBgMaintenancePage && maintenancePageUiUrl) {
const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`;
const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`;
const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`;
const bgMaintenanceHeadersMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-headers`;
const entrypointHttp =
config.getRawConfig().traefik.http_entrypoint;
const entrypointHttps =
config.getRawConfig().traefik.https_entrypoint;
if (!config_output.http.services) config_output.http.services = {};
if (!config_output.http.middlewares)
config_output.http.middlewares = {};
if (!config_output.http.routers) config_output.http.routers = {};
config_output.http.services![bgMaintenanceServiceName] = {
loadBalancer: {
servers: [
{
url: maintenancePageUiUrl
}
],
passHostHeader: true
}
};
config_output.http.middlewares![bgRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/maintenance-screen"
}
};
config_output.http.middlewares![
bgMaintenanceHeadersMiddlewareName
] = {
headers: {
customRequestHeaders: {
Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource
"p-host": fullDomain
}
}
};
config_output.http.routers![bgMaintenanceRouterName] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
middlewares: [
bgRewriteMiddlewareName,
bgMaintenanceHeadersMiddlewareName
],
rule: hostRule,
priority: 2000,
...(bgResource.ssl ? { tls } : {})
};
// Router to allow Next.js assets to load without rewrite
config_output.http.routers![`${bgMaintenanceRouterName}-assets`] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
middlewares: [bgMaintenanceHeadersMiddlewareName],
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 2001,
...(bgResource.ssl ? { tls } : {})
};
continue;
}
// Group targets by type and generate per-type websocket routers and services
const typeMap = new Map<string, typeof bgResource.targets>();
for (const t of bgResource.targets) {
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
typeMap.get(t.bgType)!.push(t);
}
for (const [bgType, typedTargets] of typeMap.entries()) {
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
const bgRouterName = `${bgKey}-router`;
const bgServiceName = `${bgKey}-service`;
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
const servers = typedTargets
.filter((t) => {
if (!t.siteOnline && anySiteOnline) return false;
if (t.siteType === "newt") return !!t.subnet;
return false; // browser gateway only supported on newt sites
})
.map((t) => ({
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
}))
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
config_output.http.routers![bgRouterName] = {
entryPoints: [
bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: bgServiceName,
rule: bgRule,
priority: 110, // highest - websocket path takes precedence
...(bgResource.ssl ? { tls } : {})
};
config_output.http.services![bgServiceName] = {
loadBalancer: {
servers
}
};
}
// UI: serve the browser gateway page from the internal pangolin instance.
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const uiHeadersMiddlewareName = `bg-r${bgResource.resourceId}-ui-headers`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares![uiRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: `/${primaryType}`
}
};
config_output.http.middlewares![uiHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource
"p-host": fullDomain
}
}
};
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers: [
{
url: browserGatewayUiUrl
}
]
}
};
// Assets router at higher priority so /_next files load without rewrite.
// Do NOT apply the path-rewrite middleware here — static assets must
// keep their original path; only the host headers are needed.
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: [...routerMiddlewares, uiHeadersMiddlewareName],
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
...(bgResource.ssl ? { tls } : {})
};
// Catch-all router rewrites everything on the domain to /{primaryType}
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
{
entryPoints: [entrypoint],
middlewares: [
...routerMiddlewares,
uiRewriteMiddlewareName,
uiHeadersMiddlewareName
],
service: bgUiServiceName,
rule: hostRule,
priority: 100,
...(bgResource.ssl ? { tls } : {})
};
}
}
+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 }] } : {})
};
}
+422 -365
View File
@@ -1,4 +1,13 @@
import { db, targetHealthCheck, domains } from "@server/db";
import {
db,
targetHealthCheck,
domains,
aiProviders,
resourceAiProviders,
siteResources,
siteNetworks,
exitNodes
} from "@server/db";
import {
and,
eq,
@@ -12,41 +21,64 @@ import {
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db";
import createPathRewriteMiddleware from "./middleware";
import { resources, sites, targets } from "@server/db";
import { applyPathRewriteMiddleware } from "./middleware";
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
import regionalCache from "@server/lib/cache";
import { TargetWithSite } from "./types";
import { buildWildcardTls } from "./certResolver";
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
import {
buildHttpLoadBalancerServers,
buildStickySessionCookie,
buildTcpUdpLoadBalancerServers,
buildStickySessionIp
} 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";
import {
buildBrowserGatewayResourcesMap,
buildBrowserGatewayConfig
} from "./browserGateway";
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
const redirectHttpsMiddlewareName = "redirect-to-https";
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(
exitNodeId: number,
siteTypes: string[],
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
allowRawResources = true,
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE
maintenancePageUiUrl: string | null = null,
browserGatewayUiUrl: string | null = null,
aiGatewayUrl: string | null = null
): Promise<any> {
// Get the exit node but cache it for 5 minutes to avoid hitting the DB too often
const exitNodeCacheKey = `exitNode:${exitNodeId}`;
let exitNode =
await regionalCache.get<typeof exitNodes.$inferSelect>(
exitNodeCacheKey
);
if (!exitNode) {
[exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodeId))
.limit(1);
await regionalCache.set(exitNodeCacheKey, exitNode, 300);
}
// Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources
const resourcesWithTargetsAndSites = await db
@@ -68,8 +100,15 @@ export async function getTraefikConfig(
responseHeaders: resources.responseHeaders,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
mode: resources.mode,
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
// Target fields
targetId: targets.targetId,
targetEnabled: targets.enabled,
@@ -88,7 +127,7 @@ export async function getTraefikConfig(
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId,
// Domain cert resolver fields
domainCertResolver: domains.certResolver,
@@ -116,8 +155,15 @@ export async function getTraefikConfig(
),
inArray(sites.type, siteTypes),
allowRawResources
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
: eq(resources.mode, "http")
? inArray(resources.mode, [
"http",
"udp",
"tcp",
"vnc",
"ssh",
"rdp"
]) // allow all three, plus browser-gateway modes
: inArray(resources.mode, ["http", "vnc", "ssh", "rdp"])
)
)
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -126,6 +172,9 @@ export async function getTraefikConfig(
const resourcesMap = new Map();
resourcesWithTargetsAndSites.forEach((row) => {
if (!["http", "tcp", "udp"].includes(row.mode)) {
return;
}
const resourceId = row.resourceId;
const resourceName = sanitize(row.resourceName) || "";
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
@@ -211,8 +260,81 @@ export async function getTraefikConfig(
});
});
// Group browser gateway targets by resource (SSH/VNC/RDP-mode resources
// served through the browser gateway web UI instead of a real target).
const browserGatewayResourcesMap = browserGatewayUiUrl
? buildBrowserGatewayResourcesMap(
resourcesWithTargetsAndSites,
filterOutNamespaceDomains
)
: new Map();
// Query siteResources in HTTP mode with SSL enabled and aliases, so
// Traefik generates TLS certificates for those domains even before a
// matching resource exists.
const siteResourcesWithFullDomain = await db
.select({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain
})
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"), // important so we dont double get the inference siteResources below
eq(siteResources.ssl, true),
eq(sites.exitNodeId, exitNodeId),
inArray(sites.type, siteTypes)
)
);
// Inference-mode resources have no targets/sites (their "backend" is the
// central AI gateway), so they can't be reached via the targets->sites
// join above - query them separately and include them on every exit node.
const inferenceResources = await db
.selectDistinct({
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(resources)
// .innerJoin(
// resourceAiProviders,
// eq(resources.resourceId, resourceAiProviders.resourceId)
// )
// .innerJoin(
// aiProviders,
// eq(resourceAiProviders.providerId, aiProviders.providerId)
// )
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.where(
and(
eq(resources.mode, "inference"),
eq(resources.enabled, true)
// eq(aiProviders.enabled, true)
)
);
// make sure we have at least one resource
if (resourcesMap.size === 0) {
if (
resourcesMap.size === 0 &&
inferenceResources.length === 0 &&
browserGatewayResourcesMap.size === 0 &&
siteResourcesWithFullDomain.length === 0
) {
return {};
}
@@ -256,56 +378,12 @@ export async function getTraefikConfig(
config_output.http.services = {};
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
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 tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!resource.subdomain,
domainCertResolver: resource.domainCertResolver,
preferWildcardCert: resource.preferWildcardCert
});
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
@@ -316,155 +394,41 @@ export async function getTraefikConfig(
];
// Handle path rewriting middleware
if (
resource.rewritePath !== null &&
resource.path !== null &&
resource.pathMatchType &&
resource.rewritePathType
) {
// Create a unique middleware name
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`;
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}`
);
}
}
applyPathRewriteMiddleware(
config_output,
resource.resourceId,
key,
resource.path,
resource.pathMatchType,
resource.rewritePath,
resource.rewritePathType,
routerMiddlewares
);
// Handle custom headers middleware
if (resource.requestHeaders || resource.responseHeaders || resource.setHostHeader) {
const requestHeadersObj: { [key: string]: string } = {};
const responseHeadersObj: { [key: string]: string } = {};
if (resource.requestHeaders) {
let requestHeadersArr: { name: string; value: string }[] = [];
try {
requestHeadersArr = JSON.parse(resource.requestHeaders) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse requestHeaders for resource ${resource.resourceId}: ${e}`
);
}
requestHeadersArr.forEach((header) => {
requestHeadersObj[header.name] = header.value;
});
}
if (resource.setHostHeader) {
requestHeadersObj["Host"] = resource.setHostHeader;
}
if (resource.responseHeaders) {
let responseHeadersArr: { name: string; value: string }[] = [];
try {
responseHeadersArr = JSON.parse(resource.responseHeaders) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse responseHeaders for resource ${resource.resourceId}: ${e}`
);
}
responseHeadersArr.forEach((header) => {
responseHeadersObj[header.name] = header.value;
});
}
const hasRequestHeaders = Object.keys(requestHeadersObj).length > 0;
const hasResponseHeaders = Object.keys(responseHeadersObj).length > 0;
if (hasRequestHeaders || hasResponseHeaders) {
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[headersMiddlewareName] = {
headers: {
...(hasRequestHeaders && { customRequestHeaders: requestHeadersObj }),
...(hasResponseHeaders && { customResponseHeaders: responseHeadersObj })
}
};
routerMiddlewares.push(headersMiddlewareName);
const customHeadersMiddleware = buildCustomHeadersMiddleware(
resource.requestHeaders,
resource.responseHeaders,
resource.setHostHeader,
resource.resourceId
);
if (customHeadersMiddleware) {
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[headersMiddlewareName] =
customHeadersMiddleware;
routerMiddlewares.push(headersMiddlewareName);
}
// Build routing rules
let rule = `Host(\`${fullDomain}\`)`;
// priority logic
let priority: number;
if (resource.priority && resource.priority != 100) {
priority = resource.priority;
} 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
}
}
}
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
}
}
let rule = buildHostRule(fullDomain);
const priority = computeRoutePriority(
resource.priority,
resource.path,
resource.pathMatchType
);
rule = appendPathMatch(rule, resource.path, resource.pathMatchType);
config_output.http.routers![routerName] = {
entryPoints: [
@@ -493,90 +457,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = {
loadBalancer: {
servers: (() => {
// 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
)
);
})(),
servers: buildHttpLoadBalancerServers(targets),
...(resource.stickySession
? {
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
? buildStickySessionCookie(resource.ssl)
: {})
}
};
@@ -626,75 +509,249 @@ export async function getTraefikConfig(
config_output[protocol].services[serviceName] = {
loadBalancer: {
servers: (() => {
// 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}`
};
}
});
})(),
servers: buildTcpUdpLoadBalancerServers(targets),
...(resource.proxyProtocol && protocol == "tcp"
? {
serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues?
}
: {}),
...(resource.stickySession
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
...(resource.stickySession ? buildStickySessionIp() : {})
}
};
}
}
if (browserGatewayUiUrl) {
buildBrowserGatewayConfig({
config_output,
browserGatewayResourcesMap,
browserGatewayUiUrl,
maintenancePageUiUrl,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls: ({
fullDomain,
hasSubdomain,
domainCertResolver,
preferWildcardCert
}) =>
buildWildcardTls({
fullDomain,
hasSubdomain,
domainCertResolver,
preferWildcardCert
})
});
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no
// matching resource exists yet.
if (siteResourcesWithFullDomain.length > 0) {
// Build a set of domains already covered by normal resources
const existingFullDomains = new Set<string>();
for (const resource of resourcesMap.values()) {
if (resource.fullDomain) {
existingFullDomains.add(resource.fullDomain);
}
}
buildSiteResourceAliasCertPlaceholders({
config_output,
siteResourcesWithFullDomain,
existingFullDomains,
maintenancePageUiUrl,
redirectHttpsMiddlewareName,
resolveTls: (fullDomain) =>
buildWildcardTls({
fullDomain,
hasSubdomain: true
})
});
}
if (aiGatewayUrl) {
// 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). passHostHeader would forward
// the resource's own Host, which that external host won't
// recognize, so we pin the Host header to the gateway's own host
// and smuggle the original resource host through in "p-host"
// instead.
const aiGatewayHost = getAiGatewayHost(aiGatewayUrl);
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
Object.assign(
config_output.http.middlewares,
buildAiGatewayTrustMiddlewares()
);
const aiGatewayClientIpMiddleware = buildAiGatewayClientIpMiddleware();
const enableAiGatewayClientIpHeader = !!aiGatewayClientIpMiddleware;
if (aiGatewayClientIpMiddleware) {
Object.assign(
config_output.http.middlewares,
aiGatewayClientIpMiddleware
);
}
// 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.
//
// Inference-mode resources are allowed to share a fullDomain with
// each other (see createResource.ts), and a siteResource inference
// alias can share that domain too - all of them proxy to the same
// aiGatewayUrl, so dedupe by fullDomain here (lowest resourceId
// wins, for stable output across regenerations) and skip the
// siteResource alias router for any domain already covered below.
const eligibleInferenceResources = inferenceResources
.filter((ir) => ir.enabled && ir.domainId && ir.fullDomain)
.sort((a, b) => a.resourceId - b.resourceId);
const dedupedInferenceResources = new Map<
string,
(typeof eligibleInferenceResources)[number]
>();
for (const ir of eligibleInferenceResources) {
if (!dedupedInferenceResources.has(ir.fullDomain!)) {
dedupedInferenceResources.set(ir.fullDomain!, ir);
}
}
const publicInferenceDomains = new Set<string>();
for (const ir of dedupedInferenceResources.values()) {
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = ir.fullDomain!;
const irKey = `inference-r${ir.resourceId}`;
const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`;
const rule = buildHostRule(fullDomain, ir.wildcard);
const tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!ir.subdomain,
domainCertResolver: ir.domainCertResolver,
preferWildcardCert: ir.preferWildcardCert
});
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
config_output.http.middlewares[irHeadersMiddlewareName] =
buildAiGatewayHostHeaderMiddleware(aiGatewayHost, fullDomain);
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
irHeadersMiddlewareName,
...additionalMiddlewares
];
const { routers, services } = buildAiGatewayRouterAndService({
routerName,
serviceName,
rule,
ssl: ir.ssl,
tls,
priority: 100,
routerMiddlewares,
aiGatewayUrl,
redirectHttpsMiddlewareName
});
Object.assign(config_output.http.routers, routers);
Object.assign(config_output.http.services, services);
publicInferenceDomains.add(fullDomain);
}
// Private (siteResource) inference resources: routed by their alias
// instead of a public fullDomain, and deliberately WITHOUT the
// badger middleware - no per-user auth/policy stack exists for
// siteResources today, so gating here is reachability-only for now.
const siteResourcesInference = await db
.selectDistinct({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain,
ssl: siteResources.ssl,
enabled: siteResources.enabled
})
.from(siteResources)
.where(
and(
eq(siteResources.mode, "inference"),
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain)
)
);
if (exitNode) {
for (const sr of siteResourcesInference) {
if (!sr.enabled || !sr.fullDomain) continue;
// A public inference resource already owns a router for
// this exact fullDomain - both point at the same AI gateway,
// so avoid registering a duplicate router for it here.
if (publicInferenceDomains.has(sr.fullDomain)) continue;
if (!config_output.http.routers)
config_output.http.routers = {};
if (!config_output.http.services)
config_output.http.services = {};
const fullDomain = sr.fullDomain;
const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`;
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
// siteResource aliases don't have a per-domain cert resolver
// stored, so always fall back to the global defaults.
const tls = buildWildcardTls({
fullDomain,
hasSubdomain: true
});
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[srHeadersMiddlewareName] =
buildAiGatewayHostHeaderMiddleware(
aiGatewayHost,
fullDomain
);
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
...(enableAiGatewayClientIpHeader
? [AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME]
: []),
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
srHeadersMiddlewareName,
...additionalMiddlewares
];
const { routers, services } = buildAiGatewayRouterAndService({
routerName,
serviceName,
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.
routerMiddlewares,
aiGatewayUrl,
redirectHttpsMiddlewareName
});
Object.assign(config_output.http.routers, routers);
Object.assign(config_output.http.services, services);
}
}
}
return config_output;
}
+78
View File
@@ -0,0 +1,78 @@
import logger from "@server/logger";
function parseHeaders(
headers: string,
label: string,
resourceId: number
): { name: string; value: string }[] {
try {
return JSON.parse(headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse ${label} for resource ${resourceId}: ${e}`
);
return [];
}
}
/**
* Build the custom headers middleware definition for a resource's
* custom request/response headers + setHostHeader config. Returns null when
* there are no headers to set, so the caller can skip attaching the
* middleware.
*/
export function buildCustomHeadersMiddleware(
requestHeaders: string | null | undefined,
responseHeaders: string | null | undefined,
setHostHeader: string | null | undefined,
resourceId: number
): {
headers: {
customRequestHeaders?: { [key: string]: string };
customResponseHeaders?: { [key: string]: string };
};
} | null {
const requestHeadersObj: { [key: string]: string } = {};
const responseHeadersObj: { [key: string]: string } = {};
if (requestHeaders) {
parseHeaders(requestHeaders, "requestHeaders", resourceId).forEach(
(header) => {
requestHeadersObj[header.name] = header.value;
}
);
}
if (setHostHeader) {
requestHeadersObj["Host"] = setHostHeader;
}
if (responseHeaders) {
parseHeaders(responseHeaders, "responseHeaders", resourceId).forEach(
(header) => {
responseHeadersObj[header.name] = header.value;
}
);
}
const hasRequestHeaders = Object.keys(requestHeadersObj).length > 0;
const hasResponseHeaders = Object.keys(responseHeadersObj).length > 0;
if (!hasRequestHeaders && !hasResponseHeaders) {
return null;
}
return {
headers: {
...(hasRequestHeaders && {
customRequestHeaders: requestHeadersObj
}),
...(hasResponseHeaders && {
customResponseHeaders: responseHeadersObj
})
}
};
}
+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";
/**
* 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(
middlewareName: 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;
}
+114
View File
@@ -0,0 +1,114 @@
import config from "@server/lib/config";
export type SiteResourceAliasRow = {
siteResourceId: number;
fullDomain: string | null;
};
/**
* Add placeholder Traefik routes for siteResource HTTP aliases so Traefik
* generates TLS certificates for those domains even before a matching
* resource exists. Requests that land on these routes before a real
* resource is created are served the placeholder page. TLS/cert-resolver
* handling differs between the OSS and private (pangolin-dns aware) config
* generators, so callers resolve that themselves via resolveTls - returning
* null skips the alias (no valid cert available yet).
*/
export function buildSiteResourceAliasCertPlaceholders(params: {
config_output: any;
siteResourcesWithFullDomain: SiteResourceAliasRow[];
existingFullDomains: Set<string>;
maintenancePageUiUrl: string | null;
redirectHttpsMiddlewareName: string;
resolveTls: (fullDomain: string) => any | null;
}): void {
const {
config_output,
siteResourcesWithFullDomain,
existingFullDomains,
maintenancePageUiUrl,
redirectHttpsMiddlewareName,
resolveTls
} = params;
if (siteResourcesWithFullDomain.length === 0 || !maintenancePageUiUrl) {
return;
}
for (const sr of siteResourcesWithFullDomain) {
if (!sr.fullDomain) continue;
// Skip if this alias is already handled by a resource router
if (existingFullDomains.has(sr.fullDomain)) continue;
const fullDomain = sr.fullDomain;
const srKey = `site-resource-cert-${sr.siteResourceId}`;
const siteResourceServiceName = `${srKey}-service`;
const siteResourceRouterName = `${srKey}-router`;
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
if (!config_output.http.routers) {
config_output.http.routers = {};
}
if (!config_output.http.services) {
config_output.http.services = {};
}
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
// Service pointing at the internal maintenance/Next.js page
config_output.http.services[siteResourceServiceName] = {
loadBalancer: {
servers: [
{
url: maintenancePageUiUrl
}
],
passHostHeader: true
}
};
// Middleware that rewrites any path to /private-maintenance-screen
config_output.http.middlewares[siteResourceRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/private-maintenance-screen"
}
};
// HTTP -> HTTPS redirect so the ACME challenge can be served
config_output.http.routers[`${siteResourceRouterName}-redirect`] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`)`,
priority: 100
};
// Determine TLS / cert-resolver configuration
const tls = resolveTls(fullDomain);
if (tls === null) {
continue;
}
// HTTPS router - presence of this entry triggers cert generation
config_output.http.routers[siteResourceRouterName] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
middlewares: [siteResourceRewriteMiddlewareName],
rule: `Host(\`${fullDomain}\`)`,
priority: 100,
tls
};
// Assets bypass router - lets Next.js static files load without rewrite
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
tls
};
}
}
+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;
};
};