From e7f5f046320822f277cfd6a2c5ccfd0a3b565c28 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 14 Aug 2026 18:06:46 -0400 Subject: [PATCH] Pass 2 bring over browser resources and http resource cert gen --- server/lib/traefik/browserGateway.ts | 399 +++++++++++++++ server/lib/traefik/getTraefikConfig.ts | 123 ++++- server/lib/traefik/siteResourceAlias.ts | 114 +++++ .../private/lib/traefik/getTraefikConfig.ts | 468 +++--------------- 4 files changed, 687 insertions(+), 417 deletions(-) create mode 100644 server/lib/traefik/browserGateway.ts create mode 100644 server/lib/traefik/siteResourceAlias.ts diff --git a/server/lib/traefik/browserGateway.ts b/server/lib/traefik/browserGateway.ts new file mode 100644 index 000000000..98a6690a6 --- /dev/null +++ b/server/lib/traefik/browserGateway.ts @@ -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 { + const map = new Map(); + + 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; + 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(); + 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 } : {}) + }; + } +} diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index c27e30f50..b755ea341 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -5,6 +5,7 @@ import { aiProviders, resourceAiProviders, siteResources, + siteNetworks, exitNodes } from "@server/db"; import { @@ -20,7 +21,7 @@ import { } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { resources, sites, Target, targets } from "@server/db"; +import { resources, sites, targets } from "@server/db"; import { applyPathRewriteMiddleware } from "./middleware"; import { sanitize, encodePath, validatePathRewriteConfig } from "./utils"; import regionalCache from "@server/lib/cache"; @@ -44,6 +45,11 @@ import { buildAiGatewayHostHeaderMiddleware, buildAiGatewayRouterAndService } from "./aiGatewayMiddlewares"; +import { + buildBrowserGatewayResourcesMap, + buildBrowserGatewayConfig +} from "./browserGateway"; +import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; @@ -54,8 +60,8 @@ export async function getTraefikConfig( 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 { // Get the exit node but cache it for 5 minutes to avoid hitting the DB too often @@ -93,8 +99,15 @@ export async function getTraefikConfig( headers: resources.headers, 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, @@ -141,8 +154,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 @@ -151,6 +171,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") @@ -235,6 +258,40 @@ 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. @@ -270,7 +327,12 @@ export async function getTraefikConfig( ); // make sure we have at least one resource - if (resourcesMap.size === 0 && inferenceResources.length === 0) { + if ( + resourcesMap.size === 0 && + inferenceResources.length === 0 && + browserGatewayResourcesMap.size === 0 && + siteResourcesWithFullDomain.length === 0 + ) { return {}; } @@ -456,6 +518,55 @@ export async function getTraefikConfig( } } + 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(); + 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 diff --git a/server/lib/traefik/siteResourceAlias.ts b/server/lib/traefik/siteResourceAlias.ts new file mode 100644 index 000000000..3c7392064 --- /dev/null +++ b/server/lib/traefik/siteResourceAlias.ts @@ -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; + 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 + }; + } +} diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 70b23ef16..354a976b2 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -79,12 +79,16 @@ import { buildAiGatewayHostHeaderMiddleware, buildAiGatewayRouterAndService } from "@server/lib/traefik/aiGatewayMiddlewares"; +import { + buildBrowserGatewayResourcesMap, + buildBrowserGatewayConfig +} from "@server/lib/traefik/browserGateway"; +import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias"; const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; const badgerMiddlewareName = "badger"; const landingRateLimitMiddlewareName = "landing-ratelimit"; -const bgRateLimitMiddlewareName = "bg-ratelimit"; export async function getTraefikConfig( exitNodeId: number, @@ -311,74 +315,12 @@ export async function getTraefikConfig( } // Group browser gateway targets by resource - 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; - }[]; - }; - const browserGatewayResourcesMap = new Map< - number, - BrowserGatewayResourceEntry - >(); - - if (browserGatewayUiUrl) { - for (const row of resourcesWithTargetsAndSites) { - if (!["ssh", "vnc", "rdp"].includes(row.mode)) { - continue; - } - if (filterOutNamespaceDomains && row.domainNamespaceId) { - continue; - } - if (!browserGatewayResourcesMap.has(row.resourceId)) { - browserGatewayResourcesMap.set(row.resourceId, { - resourceId: row.resourceId, - name: sanitize(row.resourceName) || "", - 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: [] - }); - } - browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ - targetId: row.targetId, - bgType: row.mode, - siteId: row.siteId, - siteType: row.siteType, - siteOnline: row.siteOnline, - subnet: row.subnet - }); - } - } + const browserGatewayResourcesMap = browserGatewayUiUrl + ? buildBrowserGatewayResourcesMap( + resourcesWithTargetsAndSites, + filterOutNamespaceDomains + ) + : new Map(); let siteResourcesWithFullDomain: { siteResourceId: number; @@ -513,12 +455,6 @@ export async function getTraefikConfig( average: traefikRateLimit.average, burst: traefikRateLimit.burst } - }, - [bgRateLimitMiddlewareName]: { - rateLimit: { - average: traefikRateLimit.average, - burst: traefikRateLimit.burst - } } } } @@ -840,37 +776,29 @@ export async function getTraefikConfig( } if (browserGatewayUiUrl) { - // Generate Traefik config for browser gateway resources - 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 - let tls = {}; - if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - tls = buildWildcardTls({ - fullDomain, - hasSubdomain: !!bgResource.subdomain, - domainCertResolver: bgResource.domainCertResolver, - preferWildcardCert: bgResource.preferWildcardCert - }); - } else { + buildBrowserGatewayConfig({ + config_output, + browserGatewayResourcesMap, + browserGatewayUiUrl, + maintenancePageUiUrl, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls: ({ + fullDomain, + hasSubdomain, + domainCertResolver, + preferWildcardCert + }) => { + if ( + !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns + ) { + return buildWildcardTls({ + fullDomain, + hasSubdomain, + domainCertResolver, + preferWildcardCert + }); + } const matchingCert = validCerts.find( (cert) => cert.queriedDomain === fullDomain ); @@ -878,231 +806,11 @@ export async function getTraefikConfig( logger.debug( `No matching certificate found for browser gateway domain: ${fullDomain}` ); - continue; + return null; } + return {}; } - - 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 } : {}) - }; - - 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(); - 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 } : {}) - }; - } + }); } // Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that @@ -1117,68 +825,24 @@ export async function getTraefikConfig( } } - 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 + buildSiteResourceAliasCertPlaceholders({ + config_output, + siteResourcesWithFullDomain, + existingFullDomains, + maintenancePageUiUrl, + redirectHttpsMiddlewareName, + resolveTls: (fullDomain) => { + if ( + !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns + ) { + // siteResource aliases don't have a per-domain cert + // resolver stored, so always fall back to the global + // defaults. + return buildWildcardTls({ + fullDomain, + hasSubdomain: true + }); } - }; - - // Middleware that rewrites any path to /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 - let tls: any = {}; - if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - // siteResource aliases don't have a per-domain cert resolver - // stored, so always fall back to the global defaults. - tls = buildWildcardTls({ - fullDomain, - hasSubdomain: true - }); - } else { // pangolin-dns: only add route if we already have a valid cert const matchingCert = validCerts.find( (cert) => cert.queriedDomain === fullDomain @@ -1187,29 +851,11 @@ export async function getTraefikConfig( logger.debug( `No matching certificate found for siteResource alias: ${fullDomain}` ); - continue; + return null; } + return {}; } - - // 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 - }; - } + }); } if (aiGatewayUrl) {