From de70d72e0d00914ac76de1915b32ca6c88628470 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 13 May 2026 17:33:16 -0700 Subject: [PATCH] Add gateway endpoints into the traefik config --- .../private/lib/traefik/getTraefikConfig.ts | 274 ++++++++++++++++++ server/routers/newt/buildConfiguration.ts | 24 +- .../routers/newt/handleNewtRegisterMessage.ts | 18 +- server/routers/newt/sync.ts | 21 +- src/app/rdp/RdpClient.tsx | 6 +- 5 files changed, 312 insertions(+), 31 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 481192fb5..e551a7a7a 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -12,6 +12,7 @@ */ import { + browserGatewayTarget, certificates, db, domainNamespaces, @@ -277,6 +278,115 @@ export async function getTraefikConfig( }); }); + // Query browser gateway targets for this exit node + const browserGatewayRows = await db + .select({ + // Resource fields + 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, + domainNamespaceId: domainNamespaces.domainNamespaceId, + // Browser gateway target fields + browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId, + bgType: browserGatewayTarget.type, + // Site fields + siteId: sites.siteId, + siteType: sites.type, + siteOnline: sites.online, + subnet: sites.subnet, + siteExitNodeId: sites.exitNodeId + }) + .from(browserGatewayTarget) + .innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId)) + .innerJoin( + resources, + eq(resources.resourceId, browserGatewayTarget.resourceId) + ) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) + .leftJoin( + domainNamespaces, + eq(domainNamespaces.domainId, resources.domainId) + ) + .where( + and( + eq(resources.enabled, true), + or( + eq(sites.exitNodeId, exitNodeId), + and( + isNull(sites.exitNodeId), + sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, + eq(sites.type, "local"), + sql`(${build != "saas" ? 1 : 0} = 1)` + ) + ), + inArray(sites.type, siteTypes) + ) + ); + + // 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; + targets: { + browserGatewayTargetId: number; + bgType: string; + siteId: number; + siteType: string; + siteOnline: boolean | null; + subnet: string | null; + siteExitNodeId: number | null; + }[]; + }; + const browserGatewayResourcesMap = new Map< + number, + BrowserGatewayResourceEntry + >(); + + for (const row of browserGatewayRows) { + 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, + targets: [] + }); + } + browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ + browserGatewayTargetId: row.browserGatewayTargetId, + bgType: row.bgType, + siteId: row.siteId, + siteType: row.siteType, + siteOnline: row.siteOnline, + subnet: row.subnet, + siteExitNodeId: row.siteExitNodeId + }); + } + let siteResourcesWithFullDomain: { siteResourceId: number; fullDomain: string | null; @@ -324,6 +434,12 @@ export async function getTraefikConfig( domains.add(sr.fullDomain); } } + // Include browser gateway resource domains + for (const bgResource of browserGatewayResourcesMap.values()) { + if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) { + domains.add(bgResource.fullDomain); + } + } // get the valid certs for these domains validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); @@ -925,6 +1041,164 @@ export async function getTraefikConfig( } } + // 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, + ...additionalMiddlewares + ]; + + const hostRule = `Host(\`${fullDomain}\`)`; + + // Build TLS config + let tls = {}; + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { + const domainParts = fullDomain.split("."); + let wildCard: string; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + if (!bgResource.subdomain) { + wildCard = fullDomain; + } + + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; + const resolverName = bgResource.domainCertResolver + ? bgResource.domainCertResolver.trim() + : globalDefaultResolver; + const preferWildcard = + bgResource.preferWildcardCert !== undefined && + bgResource.preferWildcardCert !== null + ? bgResource.preferWildcardCert + : globalDefaultPreferWildcard; + + tls = { + certResolver: resolverName, + ...(preferWildcard ? { domains: [{ main: wildCard }] } : {}) + }; + } else { + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === fullDomain + ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for browser gateway domain: ${fullDomain}` + ); + 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); + + // 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, // higher than 105 (UI router) to match /gateway/* first + ...(bgResource.ssl ? { tls } : {}) + }; + + config_output.http.services![bgServiceName] = { + loadBalancer: { + servers + } + }; + } + + // UI router: serve the browser gateway pages from the internal pangolin instance + // Covers /{type} paths for each configured type plus Next.js assets + const internalHost = config.getRawConfig().server.internal_hostname; + const internalPort = config.getRawConfig().server.next_port; + + const typePaths = Array.from(typeMap.keys()) + .map((t) => `PathPrefix(\`/${t}\`)`) + .join(" || "); + const uiRule = `${hostRule} && (${typePaths} || PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`; + + config_output.http.services![bgUiServiceName] = { + loadBalancer: { + servers: [ + { + url: `http://${internalHost}:${internalPort}` + } + ] + } + }; + + config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] = + { + entryPoints: [ + bgResource.ssl + ? config.getRawConfig().traefik.https_entrypoint + : config.getRawConfig().traefik.http_entrypoint + ], + middlewares: routerMiddlewares, + service: bgUiServiceName, + rule: uiRule, + priority: 105, + ...(bgResource.ssl ? { tls } : {}) + }; + } + // 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. diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index ce126f9c5..fb398236a 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -235,6 +235,11 @@ export async function buildTargetConfigurationForNewtClient( .from(targetHealthCheck) .where(eq(targetHealthCheck.siteId, siteId)); + const allBrowserGatewayTargets = await db + .select() + .from(browserGatewayTarget) + .where(eq(browserGatewayTarget.siteId, siteId)); + const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { // Filter out invalid targets @@ -306,18 +311,17 @@ export async function buildTargetConfigurationForNewtClient( (target) => target !== null ); + const browserGatewayTargets = allBrowserGatewayTargets.map((t) => ({ + id: t.browserGatewayTargetId, + type: t.type, + destination: t.destination, + destinationPort: t.destinationPort + })); + return { validHealthCheckTargets, tcpTargets, - udpTargets + udpTargets, + browserGatewayTargets }; } - -export async function buildBrowserGatewayTargetConfigurationForNewtClient( - siteId: number -): Promise { - return await db - .select() - .from(browserGatewayTarget) - .where(eq(browserGatewayTarget.siteId, siteId)); -} diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index f3902a35d..bd4aaacb3 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -43,8 +43,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { const siteId = newt.siteId; - const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } = - message.data; + const { + publicKey, + pingResults, + newtVersion, + backwardsCompatible, + chainId + } = message.data; if (!publicKey) { logger.warn("Public key not provided"); return; @@ -191,8 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .where(eq(newts.newtId, newt.newtId)); } - const { tcpTargets, udpTargets, validHealthCheckTargets } = - await buildTargetConfigurationForNewtClient(siteId, newtVersion); + const { + tcpTargets, + udpTargets, + validHealthCheckTargets, + browserGatewayTargets + } = await buildTargetConfigurationForNewtClient(siteId, newtVersion); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` @@ -212,6 +221,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { tcp: tcpTargets }, healthCheckTargets: validHealthCheckTargets, + browserGatewayTargets: browserGatewayTargets, chainId: chainId } }, diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts index d7ceefde4..b8f152bec 100644 --- a/server/routers/newt/sync.ts +++ b/server/routers/newt/sync.ts @@ -3,18 +3,18 @@ import { eq } from "drizzle-orm"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { - buildBrowserGatewayTargetConfigurationForNewtClient, buildClientConfigurationForNewtClient, buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; export async function sendNewtSyncMessage(newt: Newt, site: Site) { - const { tcpTargets, udpTargets, validHealthCheckTargets } = - await buildTargetConfigurationForNewtClient(site.siteId); - - const browserGatewayTargets = - await buildBrowserGatewayTargetConfigurationForNewtClient(site.siteId); + const { + tcpTargets, + udpTargets, + validHealthCheckTargets, + browserGatewayTargets + } = await buildTargetConfigurationForNewtClient(site.siteId); let exitNode: ExitNode | undefined; if (site.exitNodeId) { @@ -41,14 +41,7 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) { healthCheckTargets: validHealthCheckTargets, peers: peers, clientTargets: targets, - browserGatewayTargets: browserGatewayTargets.map((t) => ({ - id: t.browserGatewayTargetId, - resourceId: t.resourceId, - siteId: t.siteId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort - })) + browserGatewayTargets: browserGatewayTargets } }, { diff --git a/src/app/rdp/RdpClient.tsx b/src/app/rdp/RdpClient.tsx index b3d166d76..15ca74f9e 100644 --- a/src/app/rdp/RdpClient.tsx +++ b/src/app/rdp/RdpClient.tsx @@ -58,11 +58,11 @@ const isIronError = (error: unknown): error is IronError => { export default function RdpClient() { const [form, setForm] = useState({ username: "Administrator", - password: "Wdvwy1W*ITK-(OK.sW?nVK%?mTl30wL0", - gatewayAddress: "ws://localhost:7171/jet/rdp", + password: "Password123!", + gatewayAddress: "ws://localhost:8082/rdp", hostname: "172.31.3.58:3389", domain: "", - authtoken: "abc123", + authtoken: "pangolin-browser-gateway-dev", kdcProxyUrl: "", pcb: "", desktopWidth: 1280,