mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-15 16:59:35 +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,
|
||||
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<any> {
|
||||
// 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<string>();
|
||||
for (const resource of resourcesMap.values()) {
|
||||
if (resource.fullDomain) {
|
||||
existingFullDomains.add(resource.fullDomain);
|
||||
}
|
||||
}
|
||||
|
||||
buildSiteResourceAliasCertPlaceholders({
|
||||
config_output,
|
||||
siteResourcesWithFullDomain,
|
||||
existingFullDomains,
|
||||
maintenancePageUiUrl,
|
||||
redirectHttpsMiddlewareName,
|
||||
resolveTls: (fullDomain) =>
|
||||
buildWildcardTls({
|
||||
fullDomain,
|
||||
hasSubdomain: true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (aiGatewayUrl) {
|
||||
// The AI gateway may live on a different host than the inference
|
||||
// resource itself (e.g. a remote exit node forwarding to the
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user