mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-15 08:49:59 +02:00
Pass 2 bring over browser resources and http resource cert gen
This commit is contained in:
@@ -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 } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
aiProviders,
|
aiProviders,
|
||||||
resourceAiProviders,
|
resourceAiProviders,
|
||||||
siteResources,
|
siteResources,
|
||||||
|
siteNetworks,
|
||||||
exitNodes
|
exitNodes
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { resources, sites, Target, targets } from "@server/db";
|
import { resources, sites, targets } from "@server/db";
|
||||||
import { applyPathRewriteMiddleware } from "./middleware";
|
import { applyPathRewriteMiddleware } from "./middleware";
|
||||||
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
||||||
import regionalCache from "@server/lib/cache";
|
import regionalCache from "@server/lib/cache";
|
||||||
@@ -44,6 +45,11 @@ import {
|
|||||||
buildAiGatewayHostHeaderMiddleware,
|
buildAiGatewayHostHeaderMiddleware,
|
||||||
buildAiGatewayRouterAndService
|
buildAiGatewayRouterAndService
|
||||||
} from "./aiGatewayMiddlewares";
|
} from "./aiGatewayMiddlewares";
|
||||||
|
import {
|
||||||
|
buildBrowserGatewayResourcesMap,
|
||||||
|
buildBrowserGatewayConfig
|
||||||
|
} from "./browserGateway";
|
||||||
|
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
@@ -54,8 +60,8 @@ export async function getTraefikConfig(
|
|||||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
|
maintenancePageUiUrl: string | null = null,
|
||||||
browserGatewayUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
|
browserGatewayUiUrl: string | null = null,
|
||||||
aiGatewayUrl: string | null = null
|
aiGatewayUrl: string | null = null
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// Get the exit node but cache it for 5 minutes to avoid hitting the DB too often
|
// 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,
|
headers: resources.headers,
|
||||||
proxyProtocol: resources.proxyProtocol,
|
proxyProtocol: resources.proxyProtocol,
|
||||||
proxyProtocolVersion: resources.proxyProtocolVersion,
|
proxyProtocolVersion: resources.proxyProtocolVersion,
|
||||||
|
wildcard: resources.wildcard,
|
||||||
mode: resources.mode,
|
mode: resources.mode,
|
||||||
|
|
||||||
|
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
||||||
|
maintenanceModeType: resources.maintenanceModeType,
|
||||||
|
maintenanceTitle: resources.maintenanceTitle,
|
||||||
|
maintenanceMessage: resources.maintenanceMessage,
|
||||||
|
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
|
||||||
|
|
||||||
// Target fields
|
// Target fields
|
||||||
targetId: targets.targetId,
|
targetId: targets.targetId,
|
||||||
targetEnabled: targets.enabled,
|
targetEnabled: targets.enabled,
|
||||||
@@ -141,8 +154,15 @@ export async function getTraefikConfig(
|
|||||||
),
|
),
|
||||||
inArray(sites.type, siteTypes),
|
inArray(sites.type, siteTypes),
|
||||||
allowRawResources
|
allowRawResources
|
||||||
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
|
? inArray(resources.mode, [
|
||||||
: eq(resources.mode, "http")
|
"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
|
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||||
@@ -151,6 +171,9 @@ export async function getTraefikConfig(
|
|||||||
const resourcesMap = new Map();
|
const resourcesMap = new Map();
|
||||||
|
|
||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
|
if (!["http", "tcp", "udp"].includes(row.mode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
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
|
// Inference-mode resources have no targets/sites (their "backend" is the
|
||||||
// central AI gateway), so they can't be reached via the targets->sites
|
// 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.
|
// 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
|
// 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 {};
|
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<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) {
|
if (aiGatewayUrl) {
|
||||||
// The AI gateway may live on a different host than the inference
|
// The AI gateway may live on a different host than the inference
|
||||||
// resource itself (e.g. a remote exit node forwarding to the
|
// resource itself (e.g. a remote exit node forwarding to the
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,12 +79,16 @@ import {
|
|||||||
buildAiGatewayHostHeaderMiddleware,
|
buildAiGatewayHostHeaderMiddleware,
|
||||||
buildAiGatewayRouterAndService
|
buildAiGatewayRouterAndService
|
||||||
} from "@server/lib/traefik/aiGatewayMiddlewares";
|
} 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 redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
const redirectToRootMiddlewareName = "redirect-to-root";
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
const landingRateLimitMiddlewareName = "landing-ratelimit";
|
const landingRateLimitMiddlewareName = "landing-ratelimit";
|
||||||
const bgRateLimitMiddlewareName = "bg-ratelimit";
|
|
||||||
|
|
||||||
export async function getTraefikConfig(
|
export async function getTraefikConfig(
|
||||||
exitNodeId: number,
|
exitNodeId: number,
|
||||||
@@ -311,74 +315,12 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group browser gateway targets by resource
|
// Group browser gateway targets by resource
|
||||||
type BrowserGatewayResourceEntry = {
|
const browserGatewayResourcesMap = browserGatewayUiUrl
|
||||||
resourceId: number;
|
? buildBrowserGatewayResourcesMap(
|
||||||
name: string;
|
resourcesWithTargetsAndSites,
|
||||||
fullDomain: string | null;
|
filterOutNamespaceDomains
|
||||||
ssl: boolean | null;
|
)
|
||||||
subdomain: string | null;
|
: new Map();
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let siteResourcesWithFullDomain: {
|
let siteResourcesWithFullDomain: {
|
||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
@@ -513,12 +455,6 @@ export async function getTraefikConfig(
|
|||||||
average: traefikRateLimit.average,
|
average: traefikRateLimit.average,
|
||||||
burst: traefikRateLimit.burst
|
burst: traefikRateLimit.burst
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[bgRateLimitMiddlewareName]: {
|
|
||||||
rateLimit: {
|
|
||||||
average: traefikRateLimit.average,
|
|
||||||
burst: traefikRateLimit.burst
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,37 +776,29 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (browserGatewayUiUrl) {
|
if (browserGatewayUiUrl) {
|
||||||
// Generate Traefik config for browser gateway resources
|
buildBrowserGatewayConfig({
|
||||||
const browserGatewayPort = 39999;
|
config_output,
|
||||||
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
|
browserGatewayResourcesMap,
|
||||||
if (!bgResource.enabled) continue;
|
browserGatewayUiUrl,
|
||||||
if (!bgResource.domainId) continue;
|
maintenancePageUiUrl,
|
||||||
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,
|
badgerMiddlewareName,
|
||||||
bgRateLimitMiddlewareName,
|
redirectHttpsMiddlewareName,
|
||||||
...additionalMiddlewares
|
resolveTls: ({
|
||||||
];
|
|
||||||
|
|
||||||
const hostRule = `Host(\`${fullDomain}\`)`;
|
|
||||||
|
|
||||||
// Build TLS config
|
|
||||||
let tls = {};
|
|
||||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
|
||||||
tls = buildWildcardTls({
|
|
||||||
fullDomain,
|
fullDomain,
|
||||||
hasSubdomain: !!bgResource.subdomain,
|
hasSubdomain,
|
||||||
domainCertResolver: bgResource.domainCertResolver,
|
domainCertResolver,
|
||||||
preferWildcardCert: bgResource.preferWildcardCert
|
preferWildcardCert
|
||||||
|
}) => {
|
||||||
|
if (
|
||||||
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
|
) {
|
||||||
|
return buildWildcardTls({
|
||||||
|
fullDomain,
|
||||||
|
hasSubdomain,
|
||||||
|
domainCertResolver,
|
||||||
|
preferWildcardCert
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
const matchingCert = validCerts.find(
|
const matchingCert = validCerts.find(
|
||||||
(cert) => cert.queriedDomain === fullDomain
|
(cert) => cert.queriedDomain === fullDomain
|
||||||
);
|
);
|
||||||
@@ -878,231 +806,11 @@ export async function getTraefikConfig(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`No matching certificate found for browser gateway domain: ${fullDomain}`
|
`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<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 } : {})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||||
@@ -1117,68 +825,24 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const sr of siteResourcesWithFullDomain) {
|
buildSiteResourceAliasCertPlaceholders({
|
||||||
if (!sr.fullDomain) continue;
|
config_output,
|
||||||
|
siteResourcesWithFullDomain,
|
||||||
// Skip if this alias is already handled by a resource router
|
existingFullDomains,
|
||||||
if (existingFullDomains.has(sr.fullDomain)) continue;
|
maintenancePageUiUrl,
|
||||||
|
redirectHttpsMiddlewareName,
|
||||||
const fullDomain = sr.fullDomain;
|
resolveTls: (fullDomain) => {
|
||||||
const srKey = `site-resource-cert-${sr.siteResourceId}`;
|
if (
|
||||||
const siteResourceServiceName = `${srKey}-service`;
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
const siteResourceRouterName = `${srKey}-router`;
|
) {
|
||||||
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
|
// siteResource aliases don't have a per-domain cert
|
||||||
|
// resolver stored, so always fall back to the global
|
||||||
if (!config_output.http.routers) {
|
// defaults.
|
||||||
config_output.http.routers = {};
|
return buildWildcardTls({
|
||||||
}
|
|
||||||
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 /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,
|
fullDomain,
|
||||||
hasSubdomain: true
|
hasSubdomain: true
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
// pangolin-dns: only add route if we already have a valid cert
|
// pangolin-dns: only add route if we already have a valid cert
|
||||||
const matchingCert = validCerts.find(
|
const matchingCert = validCerts.find(
|
||||||
(cert) => cert.queriedDomain === fullDomain
|
(cert) => cert.queriedDomain === fullDomain
|
||||||
@@ -1187,29 +851,11 @@ export async function getTraefikConfig(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`No matching certificate found for siteResource alias: ${fullDomain}`
|
`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) {
|
if (aiGatewayUrl) {
|
||||||
|
|||||||
Reference in New Issue
Block a user