mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-01 16:26:39 +00:00
Chungus
This commit is contained in:
1076
server/lib/traefik/TraefikConfigManager.ts
Normal file
1076
server/lib/traefik/TraefikConfigManager.ts
Normal file
File diff suppressed because it is too large
Load Diff
665
server/lib/traefik/getTraefikConfig.ts
Normal file
665
server/lib/traefik/getTraefikConfig.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
import { db, exitNodes, targetHealthCheck } from "@server/db";
|
||||
import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||
import { build } from "@server/build";
|
||||
import createPathRewriteMiddleware from "./middleware";
|
||||
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const badgerMiddlewareName = "badger";
|
||||
|
||||
|
||||
function validatePathRewriteConfig(
|
||||
path: string | null,
|
||||
pathMatchType: string | null,
|
||||
rewritePath: string | null,
|
||||
rewritePathType: string | null
|
||||
): { isValid: boolean; error?: string } {
|
||||
// If no path matching is configured, no rewriting is possible
|
||||
if (!path || !pathMatchType) {
|
||||
if (rewritePath || rewritePathType) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: "Path rewriting requires path matching to be configured"
|
||||
};
|
||||
}
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
if (rewritePathType !== "stripPrefix") {
|
||||
if ((rewritePath && !rewritePathType) || (!rewritePath && rewritePathType)) {
|
||||
return { isValid: false, error: "Both rewritePath and rewritePathType must be specified together" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!rewritePath || !rewritePathType) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
const validPathMatchTypes = ["exact", "prefix", "regex"];
|
||||
if (!validPathMatchTypes.includes(pathMatchType)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Invalid pathMatchType: ${pathMatchType}. Must be one of: ${validPathMatchTypes.join(", ")}`
|
||||
};
|
||||
}
|
||||
|
||||
const validRewritePathTypes = ["exact", "prefix", "regex", "stripPrefix"];
|
||||
if (!validRewritePathTypes.includes(rewritePathType)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Invalid rewritePathType: ${rewritePathType}. Must be one of: ${validRewritePathTypes.join(", ")}`
|
||||
};
|
||||
}
|
||||
|
||||
if (pathMatchType === "regex") {
|
||||
try {
|
||||
new RegExp(path);
|
||||
} catch (e) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `Invalid regex pattern in path: ${path}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Additional validation for stripPrefix
|
||||
if (rewritePathType === "stripPrefix") {
|
||||
if (pathMatchType !== "prefix") {
|
||||
logger.warn(`stripPrefix rewrite type is most effective with prefix path matching. Current match type: ${pathMatchType}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export async function getTraefikConfig(
|
||||
exitNodeId: number,
|
||||
siteTypes: string[],
|
||||
filterOutNamespaceDomains = false,
|
||||
generateLoginPageRouters = false
|
||||
): Promise<any> {
|
||||
// Define extended target type with site information
|
||||
type TargetWithSite = Target & {
|
||||
site: {
|
||||
siteId: number;
|
||||
type: string;
|
||||
subnet: string | null;
|
||||
exitNodeId: number | null;
|
||||
online: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// Get resources with their targets and sites in a single optimized query
|
||||
// Start from sites on this exit node, then join to targets and resources
|
||||
const resourcesWithTargetsAndSites = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
stickySession: resources.stickySession,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader,
|
||||
enableProxy: resources.enableProxy,
|
||||
headers: resources.headers,
|
||||
// Target fields
|
||||
targetId: targets.targetId,
|
||||
targetEnabled: targets.enabled,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
hcHealth: targetHealthCheck.hcHealth,
|
||||
path: targets.path,
|
||||
pathMatchType: targets.pathMatchType,
|
||||
rewritePath: targets.rewritePath,
|
||||
rewritePathType: targets.rewritePathType,
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
exitNodeId: sites.exitNodeId
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
||||
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
||||
.leftJoin(
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(targets.enabled, true),
|
||||
eq(resources.enabled, true),
|
||||
or(eq(sites.exitNodeId, exitNodeId), isNull(sites.exitNodeId)),
|
||||
or(
|
||||
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
|
||||
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
|
||||
),
|
||||
inArray(sites.type, siteTypes),
|
||||
config.getRawConfig().traefik.allow_raw_resources
|
||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||
: eq(resources.http, true)
|
||||
)
|
||||
);
|
||||
|
||||
// Group by resource and include targets with their unique site data
|
||||
const resourcesMap = new Map();
|
||||
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths
|
||||
const pathMatchType = row.pathMatchType || "";
|
||||
const rewritePath = row.rewritePath || "";
|
||||
const rewritePathType = row.rewritePathType || "";
|
||||
|
||||
// Create a unique key combining resourceId, path config, and rewrite config
|
||||
const pathKey = [targetPath, pathMatchType, rewritePath, rewritePathType]
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
|
||||
if (!resourcesMap.has(mapKey)) {
|
||||
const validation = validatePathRewriteConfig(
|
||||
row.path,
|
||||
row.pathMatchType,
|
||||
row.rewritePath,
|
||||
row.rewritePathType
|
||||
);
|
||||
|
||||
if (!validation.isValid) {
|
||||
logger.error(`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
resourcesMap.set(mapKey, {
|
||||
resourceId: row.resourceId,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
proxyPort: row.proxyPort,
|
||||
protocol: row.protocol,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
stickySession: row.stickySession,
|
||||
tlsServerName: row.tlsServerName,
|
||||
setHostHeader: row.setHostHeader,
|
||||
enableProxy: row.enableProxy,
|
||||
targets: [],
|
||||
headers: row.headers,
|
||||
path: row.path, // the targets will all have the same path
|
||||
pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType
|
||||
rewritePath: row.rewritePath,
|
||||
rewritePathType: row.rewritePathType
|
||||
});
|
||||
}
|
||||
|
||||
// Add target with its associated site data
|
||||
resourcesMap.get(mapKey).targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
method: row.method,
|
||||
port: row.port,
|
||||
internalPort: row.internalPort,
|
||||
enabled: row.targetEnabled,
|
||||
rewritePath: row.rewritePath,
|
||||
rewritePathType: row.rewritePathType,
|
||||
site: {
|
||||
siteId: row.siteId,
|
||||
type: row.siteType,
|
||||
subnet: row.subnet,
|
||||
exitNodeId: row.exitNodeId,
|
||||
online: row.siteOnline
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// make sure we have at least one resource
|
||||
if (resourcesMap.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const config_output: any = {
|
||||
http: {
|
||||
middlewares: {
|
||||
[redirectHttpsMiddlewareName]: {
|
||||
redirectScheme: {
|
||||
scheme: "https"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// get the key and the resource
|
||||
for (const [key, resource] of resourcesMap.entries()) {
|
||||
const targets = resource.targets;
|
||||
|
||||
const sanatizedKey = sanitizeForMiddlewareName(key);
|
||||
|
||||
const routerName = `${sanatizedKey}-router`;
|
||||
const serviceName = `${sanatizedKey}-service`;
|
||||
const fullDomain = `${resource.fullDomain}`;
|
||||
const transportName = `${sanatizedKey}-transport`;
|
||||
const headersMiddlewareName = `${sanatizedKey}-headers-middleware`;
|
||||
|
||||
if (!resource.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resource.http) {
|
||||
if (!resource.domainId || !resource.fullDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize routers and services if they don't exist
|
||||
if (!config_output.http.routers) {
|
||||
config_output.http.routers = {};
|
||||
}
|
||||
if (!config_output.http.services) {
|
||||
config_output.http.services = {};
|
||||
}
|
||||
|
||||
const domainParts = fullDomain.split(".");
|
||||
let wildCard;
|
||||
if (domainParts.length <= 2) {
|
||||
wildCard = `*.${domainParts.join(".")}`;
|
||||
} else {
|
||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||
}
|
||||
|
||||
if (!resource.subdomain) {
|
||||
wildCard = resource.fullDomain;
|
||||
}
|
||||
|
||||
const configDomain = config.getDomain(resource.domainId);
|
||||
|
||||
let certResolver: string, preferWildcardCert: boolean;
|
||||
if (!configDomain) {
|
||||
certResolver = config.getRawConfig().traefik.cert_resolver;
|
||||
preferWildcardCert =
|
||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||
} else {
|
||||
certResolver = configDomain.cert_resolver;
|
||||
preferWildcardCert = configDomain.prefer_wildcard_cert;
|
||||
}
|
||||
|
||||
let tls = {};
|
||||
if (build == "oss") {
|
||||
tls = {
|
||||
certResolver: certResolver,
|
||||
...(preferWildcardCert
|
||||
? {
|
||||
domains: [
|
||||
{
|
||||
main: wildCard
|
||||
}
|
||||
]
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
const additionalMiddlewares =
|
||||
config.getRawConfig().traefik.additional_middlewares || [];
|
||||
|
||||
const routerMiddlewares = [
|
||||
badgerMiddlewareName,
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
// Handle path rewriting middleware
|
||||
if (resource.rewritePath &&
|
||||
resource.path &&
|
||||
resource.pathMatchType &&
|
||||
resource.rewritePathType) {
|
||||
|
||||
// Create a unique middleware name
|
||||
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${sanitizeForMiddlewareName(key)}`;
|
||||
|
||||
try {
|
||||
const rewriteResult = createPathRewriteMiddleware(
|
||||
rewriteMiddlewareName,
|
||||
resource.path,
|
||||
resource.pathMatchType,
|
||||
resource.rewritePath,
|
||||
resource.rewritePathType
|
||||
);
|
||||
|
||||
// Initialize middlewares object if it doesn't exist
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
|
||||
// the middleware to the config
|
||||
Object.assign(config_output.http.middlewares, rewriteResult.middlewares);
|
||||
|
||||
// middlewares to the router middleware chain
|
||||
if (rewriteResult.chain) {
|
||||
// For chained middlewares (like stripPrefix + addPrefix)
|
||||
routerMiddlewares.push(...rewriteResult.chain);
|
||||
} else {
|
||||
// Single middleware
|
||||
routerMiddlewares.push(rewriteMiddlewareName);
|
||||
}
|
||||
|
||||
logger.debug(`Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle custom headers middleware
|
||||
if (resource.headers || resource.setHostHeader) {
|
||||
const headersObj: { [key: string]: string } = {};
|
||||
|
||||
if (resource.headers) {
|
||||
let headersArr: { name: string; value: string }[] = [];
|
||||
try {
|
||||
headersArr = JSON.parse(resource.headers) as {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to parse headers for resource ${resource.resourceId}: ${e}`);
|
||||
}
|
||||
|
||||
headersArr.forEach((header) => {
|
||||
headersObj[header.name] = header.value;
|
||||
});
|
||||
}
|
||||
|
||||
if (resource.setHostHeader) {
|
||||
headersObj["Host"] = resource.setHostHeader;
|
||||
}
|
||||
|
||||
if (Object.keys(headersObj).length > 0) {
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
config_output.http.middlewares[headersMiddlewareName] = {
|
||||
headers: {
|
||||
customRequestHeaders: headersObj
|
||||
}
|
||||
};
|
||||
|
||||
routerMiddlewares.push(headersMiddlewareName);
|
||||
}
|
||||
}
|
||||
|
||||
// Build routing rules
|
||||
let rule = `Host(\`${fullDomain}\`)`;
|
||||
let priority = 100;
|
||||
|
||||
if (resource.path && resource.pathMatchType) {
|
||||
priority += 1;
|
||||
// add path to rule based on match type
|
||||
let path = resource.path;
|
||||
// if the path doesn't start with a /, add it
|
||||
if (!path.startsWith("/")) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
if (resource.pathMatchType === "exact") {
|
||||
rule += ` && Path(\`${path}\`)`;
|
||||
} else if (resource.pathMatchType === "prefix") {
|
||||
rule += ` && PathPrefix(\`${path}\`)`;
|
||||
} else if (resource.pathMatchType === "regex") {
|
||||
rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex
|
||||
}
|
||||
}
|
||||
|
||||
config_output.http.routers![routerName] = {
|
||||
entryPoints: [
|
||||
resource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: routerMiddlewares,
|
||||
service: serviceName,
|
||||
rule: rule,
|
||||
priority: priority,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
if (resource.ssl) {
|
||||
config_output.http.routers![routerName + "-redirect"] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: serviceName,
|
||||
rule: rule,
|
||||
priority: priority
|
||||
};
|
||||
}
|
||||
|
||||
config_output.http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: (() => {
|
||||
// Check if any sites are online
|
||||
// THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK
|
||||
// EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE
|
||||
// RECEIVE BANDWIDTH ENDPOINT.
|
||||
|
||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||
const anySitesOnline = (
|
||||
targets as TargetWithSite[]
|
||||
).some((target: TargetWithSite) => target.site.online);
|
||||
|
||||
return (
|
||||
(targets as TargetWithSite[])
|
||||
.filter((target: TargetWithSite) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
if (
|
||||
!target.ip ||
|
||||
!target.port ||
|
||||
!target.method
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if (target.site.type === "newt") {
|
||||
if (
|
||||
!target.internalPort ||
|
||||
!target.method ||
|
||||
!target.site.subnet
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: TargetWithSite) => {
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
url: `${target.method}://${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip =
|
||||
target.site.subnet!.split("/")[0];
|
||||
return {
|
||||
url: `${target.method}://${ip}:${target.internalPort}`
|
||||
};
|
||||
}
|
||||
})
|
||||
// filter out duplicates
|
||||
.filter(
|
||||
(v, i, a) =>
|
||||
a.findIndex(
|
||||
(t) => t && v && t.url === v.url
|
||||
) === i
|
||||
)
|
||||
);
|
||||
})(),
|
||||
...(resource.stickySession
|
||||
? {
|
||||
sticky: {
|
||||
cookie: {
|
||||
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
|
||||
secure: resource.ssl,
|
||||
httpOnly: true
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
|
||||
// Add the serversTransport if TLS server name is provided
|
||||
if (resource.tlsServerName) {
|
||||
if (!config_output.http.serversTransports) {
|
||||
config_output.http.serversTransports = {};
|
||||
}
|
||||
config_output.http.serversTransports![transportName] = {
|
||||
serverName: resource.tlsServerName,
|
||||
//unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings
|
||||
// if defined in the static config and here. if not set, self-signed certs won't work
|
||||
insecureSkipVerify: true
|
||||
};
|
||||
config_output.http.services![
|
||||
serviceName
|
||||
].loadBalancer.serversTransport = transportName;
|
||||
}
|
||||
} else {
|
||||
// Non-HTTP (TCP/UDP) configuration
|
||||
if (!resource.enableProxy || !resource.proxyPort) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const protocol = resource.protocol.toLowerCase();
|
||||
const port = resource.proxyPort;
|
||||
|
||||
if (!port) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!config_output[protocol]) {
|
||||
config_output[protocol] = {
|
||||
routers: {},
|
||||
services: {}
|
||||
};
|
||||
}
|
||||
|
||||
config_output[protocol].routers[routerName] = {
|
||||
entryPoints: [`${protocol}-${port}`],
|
||||
service: serviceName,
|
||||
...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {})
|
||||
};
|
||||
|
||||
config_output[protocol].services[serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: (() => {
|
||||
// Check if any sites are online
|
||||
const anySitesOnline = (
|
||||
targets as TargetWithSite[]
|
||||
).some((target: TargetWithSite) => target.site.online);
|
||||
|
||||
return (targets as TargetWithSite[])
|
||||
.filter((target: TargetWithSite) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
if (!target.ip || !target.port) {
|
||||
return false;
|
||||
}
|
||||
} else if (target.site.type === "newt") {
|
||||
if (
|
||||
!target.internalPort ||
|
||||
!target.site.subnet
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: TargetWithSite) => {
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
address: `${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip =
|
||||
target.site.subnet!.split("/")[0];
|
||||
return {
|
||||
address: `${ip}:${target.internalPort}`
|
||||
};
|
||||
}
|
||||
});
|
||||
})(),
|
||||
...(resource.stickySession
|
||||
? {
|
||||
sticky: {
|
||||
ipStrategy: {
|
||||
depth: 0,
|
||||
sourcePort: true
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return config_output;
|
||||
}
|
||||
|
||||
function sanitizePath(path: string | null | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
// Preserve path structure for rewriting, only warn if very long
|
||||
if (trimmed.length > 1000) {
|
||||
logger.warn(`Path exceeds 1000 characters: ${trimmed.substring(0, 100)}...`);
|
||||
return trimmed.substring(0, 1000);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function sanitizeForMiddlewareName(str: string): string {
|
||||
// Replace any characters that aren't alphanumeric or dash with dash
|
||||
// and remove consecutive dashes
|
||||
return str.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
}
|
||||
11
server/lib/traefik/index.ts
Normal file
11
server/lib/traefik/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { build } from "@server/build";
|
||||
|
||||
// Import both modules
|
||||
import * as traefikModule from "./getTraefikConfig";
|
||||
import * as privateTraefikModule from "./privateGetTraefikConfig";
|
||||
|
||||
// Conditionally export Traefik configuration implementation based on build type
|
||||
const traefikImplementation = build === "oss" ? traefikModule : privateTraefikModule;
|
||||
|
||||
// Re-export all items from the selected implementation
|
||||
export const { getTraefikConfig } = traefikImplementation;
|
||||
140
server/lib/traefik/middleware.ts
Normal file
140
server/lib/traefik/middleware.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import logger from "@server/logger";
|
||||
|
||||
export default function createPathRewriteMiddleware(
|
||||
middlewareName: string,
|
||||
path: string,
|
||||
pathMatchType: string,
|
||||
rewritePath: string,
|
||||
rewritePathType: string
|
||||
): { middlewares: { [key: string]: any }; chain?: string[] } {
|
||||
const middlewares: { [key: string]: any } = {};
|
||||
|
||||
if (pathMatchType !== "regex" && !path.startsWith("/")) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
|
||||
if (
|
||||
rewritePathType !== "regex" &&
|
||||
rewritePath !== "" &&
|
||||
!rewritePath.startsWith("/")
|
||||
) {
|
||||
rewritePath = `/${rewritePath}`;
|
||||
}
|
||||
|
||||
switch (rewritePathType) {
|
||||
case "exact":
|
||||
// Replace the path with the exact rewrite path
|
||||
let exactPattern = `^${escapeRegex(path)}$`;
|
||||
middlewares[middlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: exactPattern,
|
||||
replacement: rewritePath
|
||||
}
|
||||
};
|
||||
break;
|
||||
|
||||
case "prefix":
|
||||
// Replace matched prefix with new prefix, preserve the rest
|
||||
switch (pathMatchType) {
|
||||
case "prefix":
|
||||
middlewares[middlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: `^${escapeRegex(path)}(.*)`,
|
||||
replacement: `${rewritePath}$1`
|
||||
}
|
||||
};
|
||||
break;
|
||||
case "exact":
|
||||
middlewares[middlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: `^${escapeRegex(path)}$`,
|
||||
replacement: rewritePath
|
||||
}
|
||||
};
|
||||
break;
|
||||
case "regex":
|
||||
// For regex path matching with prefix rewrite, we assume the regex has capture groups
|
||||
middlewares[middlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: path,
|
||||
replacement: rewritePath
|
||||
}
|
||||
};
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case "regex":
|
||||
// Use advanced regex replacement - works with any match type
|
||||
let regexPattern: string;
|
||||
if (pathMatchType === "regex") {
|
||||
regexPattern = path;
|
||||
} else if (pathMatchType === "prefix") {
|
||||
regexPattern = `^${escapeRegex(path)}(.*)`;
|
||||
} else {
|
||||
// exact
|
||||
regexPattern = `^${escapeRegex(path)}$`;
|
||||
}
|
||||
|
||||
middlewares[middlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: regexPattern,
|
||||
replacement: rewritePath
|
||||
}
|
||||
};
|
||||
break;
|
||||
|
||||
case "stripPrefix":
|
||||
// Strip the matched prefix and optionally add new path
|
||||
if (pathMatchType === "prefix") {
|
||||
middlewares[middlewareName] = {
|
||||
stripPrefix: {
|
||||
prefixes: [path]
|
||||
}
|
||||
};
|
||||
|
||||
// If rewritePath is provided and not empty, add it as a prefix after stripping
|
||||
if (rewritePath && rewritePath !== "" && rewritePath !== "/") {
|
||||
const addPrefixMiddlewareName = `addprefix-${middlewareName.replace("rewrite-", "")}`;
|
||||
middlewares[addPrefixMiddlewareName] = {
|
||||
addPrefix: {
|
||||
prefix: rewritePath
|
||||
}
|
||||
};
|
||||
return {
|
||||
middlewares,
|
||||
chain: [middlewareName, addPrefixMiddlewareName]
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// For exact and regex matches, use replacePathRegex to strip
|
||||
let regexPattern: string;
|
||||
if (pathMatchType === "exact") {
|
||||
regexPattern = `^${escapeRegex(path)}$`;
|
||||
} else if (pathMatchType === "regex") {
|
||||
regexPattern = path;
|
||||
} else {
|
||||
regexPattern = `^${escapeRegex(path)}`;
|
||||
}
|
||||
|
||||
const replacement = rewritePath || "/";
|
||||
middlewares[middlewareName] = {
|
||||
replacePathRegex: {
|
||||
regex: regexPattern,
|
||||
replacement: replacement
|
||||
}
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.error(`Unknown rewritePathType: ${rewritePathType}`);
|
||||
throw new Error(`Unknown rewritePathType: ${rewritePathType}`);
|
||||
}
|
||||
|
||||
return { middlewares };
|
||||
}
|
||||
|
||||
function escapeRegex(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
692
server/lib/traefik/privateGetTraefikConfig.ts
Normal file
692
server/lib/traefik/privateGetTraefikConfig.ts
Normal file
@@ -0,0 +1,692 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import {
|
||||
certificates,
|
||||
db,
|
||||
domainNamespaces,
|
||||
exitNodes,
|
||||
loginPage,
|
||||
targetHealthCheck
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import config from "@server/lib/config";
|
||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
||||
const badgerMiddlewareName = "badger";
|
||||
|
||||
export async function getTraefikConfig(
|
||||
exitNodeId: number,
|
||||
siteTypes: string[],
|
||||
filterOutNamespaceDomains = false,
|
||||
generateLoginPageRouters = false
|
||||
): Promise<any> {
|
||||
// Define extended target type with site information
|
||||
type TargetWithSite = Target & {
|
||||
site: {
|
||||
siteId: number;
|
||||
type: string;
|
||||
subnet: string | null;
|
||||
exitNodeId: number | null;
|
||||
online: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// Get resources with their targets and sites in a single optimized query
|
||||
// Start from sites on this exit node, then join to targets and resources
|
||||
const resourcesWithTargetsAndSites = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
stickySession: resources.stickySession,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader,
|
||||
enableProxy: resources.enableProxy,
|
||||
headers: resources.headers,
|
||||
// Target fields
|
||||
targetId: targets.targetId,
|
||||
targetEnabled: targets.enabled,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
hcHealth: targetHealthCheck.hcHealth,
|
||||
path: targets.path,
|
||||
pathMatchType: targets.pathMatchType,
|
||||
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
exitNodeId: sites.exitNodeId,
|
||||
// Namespace
|
||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||
// Certificate
|
||||
certificateStatus: certificates.status
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
||||
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
||||
.leftJoin(certificates, eq(certificates.domainId, resources.domainId))
|
||||
.leftJoin(
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
)
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domainNamespaces.domainId, resources.domainId)
|
||||
) // THIS IS CLOUD ONLY TO FILTER OUT THE DOMAIN NAMESPACES IF REQUIRED
|
||||
.where(
|
||||
and(
|
||||
eq(targets.enabled, true),
|
||||
eq(resources.enabled, true),
|
||||
// or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
// isNull(sites.exitNodeId)
|
||||
// ),
|
||||
or(
|
||||
ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets
|
||||
isNull(targetHealthCheck.hcHealth) // Include targets with no health check record
|
||||
),
|
||||
inArray(sites.type, siteTypes),
|
||||
config.getRawConfig().traefik.allow_raw_resources
|
||||
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||
: eq(resources.http, true)
|
||||
)
|
||||
);
|
||||
|
||||
// Group by resource and include targets with their unique site data
|
||||
const resourcesMap = new Map();
|
||||
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
const targetPath = sanitizePath(row.path) || ""; // Handle null/undefined paths
|
||||
const pathMatchType = row.pathMatchType || "";
|
||||
|
||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a unique key combining resourceId and path+pathMatchType
|
||||
const pathKey = [targetPath, pathMatchType].filter(Boolean).join("-");
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
|
||||
if (!resourcesMap.has(mapKey)) {
|
||||
resourcesMap.set(mapKey, {
|
||||
resourceId: row.resourceId,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
proxyPort: row.proxyPort,
|
||||
protocol: row.protocol,
|
||||
subdomain: row.subdomain,
|
||||
domainId: row.domainId,
|
||||
enabled: row.enabled,
|
||||
stickySession: row.stickySession,
|
||||
tlsServerName: row.tlsServerName,
|
||||
setHostHeader: row.setHostHeader,
|
||||
enableProxy: row.enableProxy,
|
||||
certificateStatus: row.certificateStatus,
|
||||
targets: [],
|
||||
headers: row.headers,
|
||||
path: row.path, // the targets will all have the same path
|
||||
pathMatchType: row.pathMatchType // the targets will all have the same pathMatchType
|
||||
});
|
||||
}
|
||||
|
||||
// Add target with its associated site data
|
||||
resourcesMap.get(mapKey).targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
method: row.method,
|
||||
port: row.port,
|
||||
internalPort: row.internalPort,
|
||||
enabled: row.targetEnabled,
|
||||
site: {
|
||||
siteId: row.siteId,
|
||||
type: row.siteType,
|
||||
subnet: row.subnet,
|
||||
exitNodeId: row.exitNodeId,
|
||||
online: row.siteOnline
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// make sure we have at least one resource
|
||||
if (resourcesMap.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const config_output: any = {
|
||||
http: {
|
||||
middlewares: {
|
||||
[redirectHttpsMiddlewareName]: {
|
||||
redirectScheme: {
|
||||
scheme: "https"
|
||||
}
|
||||
},
|
||||
[redirectToRootMiddlewareName]: {
|
||||
redirectRegex: {
|
||||
regex: "^(https?)://([^/]+)(/.*)?",
|
||||
replacement: "${1}://${2}/auth/org",
|
||||
permanent: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// get the key and the resource
|
||||
for (const [key, resource] of resourcesMap.entries()) {
|
||||
const targets = resource.targets;
|
||||
|
||||
const routerName = `${key}-router`;
|
||||
const serviceName = `${key}-service`;
|
||||
const fullDomain = `${resource.fullDomain}`;
|
||||
const transportName = `${key}-transport`;
|
||||
const headersMiddlewareName = `${key}-headers-middleware`;
|
||||
|
||||
if (!resource.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resource.http) {
|
||||
if (!resource.domainId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!resource.fullDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resource.certificateStatus !== "valid") {
|
||||
logger.debug(
|
||||
`Resource ${resource.resourceId} has certificate stats ${resource.certificateStats}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// add routers and services empty objects if they don't exist
|
||||
if (!config_output.http.routers) {
|
||||
config_output.http.routers = {};
|
||||
}
|
||||
|
||||
if (!config_output.http.services) {
|
||||
config_output.http.services = {};
|
||||
}
|
||||
|
||||
const domainParts = fullDomain.split(".");
|
||||
let wildCard;
|
||||
if (domainParts.length <= 2) {
|
||||
wildCard = `*.${domainParts.join(".")}`;
|
||||
} else {
|
||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||
}
|
||||
|
||||
if (!resource.subdomain) {
|
||||
wildCard = resource.fullDomain;
|
||||
}
|
||||
|
||||
const configDomain = config.getDomain(resource.domainId);
|
||||
|
||||
let certResolver: string, preferWildcardCert: boolean;
|
||||
if (!configDomain) {
|
||||
certResolver = config.getRawConfig().traefik.cert_resolver;
|
||||
preferWildcardCert =
|
||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||
} else {
|
||||
certResolver = configDomain.cert_resolver;
|
||||
preferWildcardCert = configDomain.prefer_wildcard_cert;
|
||||
}
|
||||
|
||||
let tls = {};
|
||||
if (build == "oss") {
|
||||
tls = {
|
||||
certResolver: certResolver,
|
||||
...(preferWildcardCert
|
||||
? {
|
||||
domains: [
|
||||
{
|
||||
main: wildCard
|
||||
}
|
||||
]
|
||||
}
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
const additionalMiddlewares =
|
||||
config.getRawConfig().traefik.additional_middlewares || [];
|
||||
|
||||
const routerMiddlewares = [
|
||||
badgerMiddlewareName,
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
if (resource.headers || resource.setHostHeader) {
|
||||
// if there are headers, parse them into an object
|
||||
const headersObj: { [key: string]: string } = {};
|
||||
if (resource.headers) {
|
||||
let headersArr: { name: string; value: string }[] = [];
|
||||
try {
|
||||
headersArr = JSON.parse(resource.headers) as {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`Failed to parse headers for resource ${resource.resourceId}: ${e}`
|
||||
);
|
||||
}
|
||||
|
||||
headersArr.forEach((header) => {
|
||||
headersObj[header.name] = header.value;
|
||||
});
|
||||
}
|
||||
|
||||
if (resource.setHostHeader) {
|
||||
headersObj["Host"] = resource.setHostHeader;
|
||||
}
|
||||
|
||||
// check if the object is not empty
|
||||
if (Object.keys(headersObj).length > 0) {
|
||||
// Add the headers middleware
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
config_output.http.middlewares[headersMiddlewareName] = {
|
||||
headers: {
|
||||
customRequestHeaders: headersObj
|
||||
}
|
||||
};
|
||||
|
||||
routerMiddlewares.push(headersMiddlewareName);
|
||||
}
|
||||
}
|
||||
|
||||
let rule = `Host(\`${fullDomain}\`)`;
|
||||
let priority = 100;
|
||||
if (resource.path && resource.pathMatchType) {
|
||||
priority += 1;
|
||||
// add path to rule based on match type
|
||||
let path = resource.path;
|
||||
// if the path doesn't start with a /, add it
|
||||
if (!path.startsWith("/")) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
if (resource.pathMatchType === "exact") {
|
||||
rule += ` && Path(\`${path}\`)`;
|
||||
} else if (resource.pathMatchType === "prefix") {
|
||||
rule += ` && PathPrefix(\`${path}\`)`;
|
||||
} else if (resource.pathMatchType === "regex") {
|
||||
rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex
|
||||
}
|
||||
}
|
||||
|
||||
config_output.http.routers![routerName] = {
|
||||
entryPoints: [
|
||||
resource.ssl
|
||||
? config.getRawConfig().traefik.https_entrypoint
|
||||
: config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: routerMiddlewares,
|
||||
service: serviceName,
|
||||
rule: rule,
|
||||
priority: priority,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
};
|
||||
|
||||
if (resource.ssl) {
|
||||
config_output.http.routers![routerName + "-redirect"] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: serviceName,
|
||||
rule: rule,
|
||||
priority: priority
|
||||
};
|
||||
}
|
||||
|
||||
config_output.http.services![serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: (() => {
|
||||
// Check if any sites are online
|
||||
// THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK
|
||||
// EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE
|
||||
// RECEIVE BANDWIDTH ENDPOINT.
|
||||
|
||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||
const anySitesOnline = (
|
||||
targets as TargetWithSite[]
|
||||
).some((target: TargetWithSite) => target.site.online);
|
||||
|
||||
return (
|
||||
(targets as TargetWithSite[])
|
||||
.filter((target: TargetWithSite) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
if (
|
||||
!target.ip ||
|
||||
!target.port ||
|
||||
!target.method
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if (target.site.type === "newt") {
|
||||
if (
|
||||
!target.internalPort ||
|
||||
!target.method ||
|
||||
!target.site.subnet
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: TargetWithSite) => {
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
url: `${target.method}://${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip =
|
||||
target.site.subnet!.split("/")[0];
|
||||
return {
|
||||
url: `${target.method}://${ip}:${target.internalPort}`
|
||||
};
|
||||
}
|
||||
})
|
||||
// filter out duplicates
|
||||
.filter(
|
||||
(v, i, a) =>
|
||||
a.findIndex(
|
||||
(t) => t && v && t.url === v.url
|
||||
) === i
|
||||
)
|
||||
);
|
||||
})(),
|
||||
...(resource.stickySession
|
||||
? {
|
||||
sticky: {
|
||||
cookie: {
|
||||
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
|
||||
secure: resource.ssl,
|
||||
httpOnly: true
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
|
||||
// Add the serversTransport if TLS server name is provided
|
||||
if (resource.tlsServerName) {
|
||||
if (!config_output.http.serversTransports) {
|
||||
config_output.http.serversTransports = {};
|
||||
}
|
||||
config_output.http.serversTransports![transportName] = {
|
||||
serverName: resource.tlsServerName,
|
||||
//unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings
|
||||
// if defined in the static config and here. if not set, self-signed certs won't work
|
||||
insecureSkipVerify: true
|
||||
};
|
||||
config_output.http.services![
|
||||
serviceName
|
||||
].loadBalancer.serversTransport = transportName;
|
||||
}
|
||||
} else {
|
||||
// Non-HTTP (TCP/UDP) configuration
|
||||
if (!resource.enableProxy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const protocol = resource.protocol.toLowerCase();
|
||||
const port = resource.proxyPort;
|
||||
|
||||
if (!port) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!config_output[protocol]) {
|
||||
config_output[protocol] = {
|
||||
routers: {},
|
||||
services: {}
|
||||
};
|
||||
}
|
||||
|
||||
config_output[protocol].routers[routerName] = {
|
||||
entryPoints: [`${protocol}-${port}`],
|
||||
service: serviceName,
|
||||
...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {})
|
||||
};
|
||||
|
||||
config_output[protocol].services[serviceName] = {
|
||||
loadBalancer: {
|
||||
servers: (() => {
|
||||
// Check if any sites are online
|
||||
const anySitesOnline = (
|
||||
targets as TargetWithSite[]
|
||||
).some((target: TargetWithSite) => target.site.online);
|
||||
|
||||
return (targets as TargetWithSite[])
|
||||
.filter((target: TargetWithSite) => {
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
if (!target.ip || !target.port) {
|
||||
return false;
|
||||
}
|
||||
} else if (target.site.type === "newt") {
|
||||
if (
|
||||
!target.internalPort ||
|
||||
!target.site.subnet
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((target: TargetWithSite) => {
|
||||
if (
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
) {
|
||||
return {
|
||||
address: `${target.ip}:${target.port}`
|
||||
};
|
||||
} else if (target.site.type === "newt") {
|
||||
const ip =
|
||||
target.site.subnet!.split("/")[0];
|
||||
return {
|
||||
address: `${ip}:${target.internalPort}`
|
||||
};
|
||||
}
|
||||
});
|
||||
})(),
|
||||
...(resource.stickySession
|
||||
? {
|
||||
sticky: {
|
||||
ipStrategy: {
|
||||
depth: 0,
|
||||
sourcePort: true
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (generateLoginPageRouters) {
|
||||
const exitNodeLoginPages = await db
|
||||
.select({
|
||||
loginPageId: loginPage.loginPageId,
|
||||
fullDomain: loginPage.fullDomain,
|
||||
exitNodeId: exitNodes.exitNodeId,
|
||||
domainId: loginPage.domainId,
|
||||
certificateStatus: certificates.status
|
||||
})
|
||||
.from(loginPage)
|
||||
.innerJoin(
|
||||
exitNodes,
|
||||
eq(exitNodes.exitNodeId, loginPage.exitNodeId)
|
||||
)
|
||||
.leftJoin(
|
||||
certificates,
|
||||
eq(certificates.domainId, loginPage.domainId)
|
||||
)
|
||||
.where(eq(exitNodes.exitNodeId, exitNodeId));
|
||||
|
||||
if (exitNodeLoginPages.length > 0) {
|
||||
if (!config_output.http.services) {
|
||||
config_output.http.services = {};
|
||||
}
|
||||
|
||||
if (!config_output.http.services["landing-service"]) {
|
||||
config_output.http.services["landing-service"] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: `http://${
|
||||
config.getRawConfig().server
|
||||
.internal_hostname
|
||||
}:${config.getRawConfig().server.next_port}`
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
for (const lp of exitNodeLoginPages) {
|
||||
if (!lp.domainId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lp.fullDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lp.certificateStatus !== "valid") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// auth-allowed:
|
||||
// rule: "Host(`auth.pangolin.internal`) && (PathRegexp(`^/auth/resource/[0-9]+$`) || PathPrefix(`/_next`))"
|
||||
// service: next-service
|
||||
// entryPoints:
|
||||
// - websecure
|
||||
|
||||
const routerName = `loginpage-${lp.loginPageId}`;
|
||||
const fullDomain = `${lp.fullDomain}`;
|
||||
|
||||
if (!config_output.http.routers) {
|
||||
config_output.http.routers = {};
|
||||
}
|
||||
|
||||
config_output.http.routers![routerName + "-router"] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.https_entrypoint
|
||||
],
|
||||
service: "landing-service",
|
||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
priority: 203,
|
||||
tls: {}
|
||||
};
|
||||
|
||||
// auth-catchall:
|
||||
// rule: "Host(`auth.example.com`)"
|
||||
// middlewares:
|
||||
// - redirect-to-root
|
||||
// service: next-service
|
||||
// entryPoints:
|
||||
// - web
|
||||
|
||||
config_output.http.routers![routerName + "-catchall"] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.https_entrypoint
|
||||
],
|
||||
middlewares: [redirectToRootMiddlewareName],
|
||||
service: "landing-service",
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 202,
|
||||
tls: {}
|
||||
};
|
||||
|
||||
// we need to add a redirect from http to https too
|
||||
config_output.http.routers![routerName + "-redirect"] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: "landing-service",
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 201
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config_output;
|
||||
}
|
||||
|
||||
function sanitizePath(path: string | null | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
// clean any non alphanumeric characters from the path and replace with dashes
|
||||
// the path cant be too long either, so limit to 50 characters
|
||||
if (path.length > 50) {
|
||||
path = path.substring(0, 50);
|
||||
}
|
||||
return path.replace(/[^a-zA-Z0-9]/g, "");
|
||||
}
|
||||
235
server/lib/traefik/traefikConfig.test.ts
Normal file
235
server/lib/traefik/traefikConfig.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { assertEquals } from "@test/assert";
|
||||
import { isDomainCoveredByWildcard } from "./TraefikConfigManager";
|
||||
|
||||
function runTests() {
|
||||
console.log('Running wildcard domain coverage tests...');
|
||||
|
||||
// Test case 1: Basic wildcard certificate at example.com
|
||||
const basicWildcardCerts = new Map([
|
||||
['example.com', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
// Should match first-level subdomains
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('level1.example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match level1.example.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('api.example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match api.example.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('www.example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match www.example.com'
|
||||
);
|
||||
|
||||
// Should match the root domain (exact match)
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('example.com', basicWildcardCerts),
|
||||
true,
|
||||
'Wildcard cert at example.com should match example.com itself'
|
||||
);
|
||||
|
||||
// Should NOT match second-level subdomains
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('level2.level1.example.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match level2.level1.example.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('deep.nested.subdomain.example.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match deep.nested.subdomain.example.com'
|
||||
);
|
||||
|
||||
// Should NOT match different domains
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.otherdomain.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match test.otherdomain.com'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('notexample.com', basicWildcardCerts),
|
||||
false,
|
||||
'Wildcard cert at example.com should NOT match notexample.com'
|
||||
);
|
||||
|
||||
// Test case 2: Multiple wildcard certificates
|
||||
const multipleWildcardCerts = new Map([
|
||||
['example.com', { exists: true, wildcard: true }],
|
||||
['test.org', { exists: true, wildcard: true }],
|
||||
['api.service.net', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('app.example.com', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match subdomain of first wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('staging.test.org', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match subdomain of second wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('v1.api.service.net', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match subdomain of third wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('deep.nested.api.service.net', multipleWildcardCerts),
|
||||
false,
|
||||
'Should NOT match multi-level subdomain of third wildcard cert'
|
||||
);
|
||||
|
||||
// Test exact domain matches for multiple certs
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('example.com', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match exact domain of first wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.org', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match exact domain of second wildcard cert'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('api.service.net', multipleWildcardCerts),
|
||||
true,
|
||||
'Should match exact domain of third wildcard cert'
|
||||
);
|
||||
|
||||
// Test case 3: Non-wildcard certificates (should not match anything)
|
||||
const nonWildcardCerts = new Map([
|
||||
['example.com', { exists: true, wildcard: false }],
|
||||
['specific.domain.com', { exists: true, wildcard: false }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('sub.example.com', nonWildcardCerts),
|
||||
false,
|
||||
'Non-wildcard cert should not match subdomains'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('example.com', nonWildcardCerts),
|
||||
false,
|
||||
'Non-wildcard cert should not match even exact domain via this function'
|
||||
);
|
||||
|
||||
// Test case 4: Non-existent certificates (should not match)
|
||||
const nonExistentCerts = new Map([
|
||||
['example.com', { exists: false, wildcard: true }],
|
||||
['missing.com', { exists: false, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('sub.example.com', nonExistentCerts),
|
||||
false,
|
||||
'Non-existent wildcard cert should not match'
|
||||
);
|
||||
|
||||
// Test case 5: Edge cases with special domain names
|
||||
const specialDomainCerts = new Map([
|
||||
['localhost', { exists: true, wildcard: true }],
|
||||
['127-0-0-1.nip.io', { exists: true, wildcard: true }],
|
||||
['xn--e1afmkfd.xn--p1ai', { exists: true, wildcard: true }] // IDN domain
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('app.localhost', specialDomainCerts),
|
||||
true,
|
||||
'Should match subdomain of localhost wildcard'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.127-0-0-1.nip.io', specialDomainCerts),
|
||||
true,
|
||||
'Should match subdomain of nip.io wildcard'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('sub.xn--e1afmkfd.xn--p1ai', specialDomainCerts),
|
||||
true,
|
||||
'Should match subdomain of IDN wildcard'
|
||||
);
|
||||
|
||||
// Test case 6: Empty input and edge cases
|
||||
const emptyCerts = new Map();
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('any.domain.com', emptyCerts),
|
||||
false,
|
||||
'Empty certificate map should not match any domain'
|
||||
);
|
||||
|
||||
// Test case 7: Domains with single character components
|
||||
const singleCharCerts = new Map([
|
||||
['a.com', { exists: true, wildcard: true }],
|
||||
['x.y.z', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('b.a.com', singleCharCerts),
|
||||
true,
|
||||
'Should match single character subdomain'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('w.x.y.z', singleCharCerts),
|
||||
true,
|
||||
'Should match single character subdomain of multi-part domain'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('v.w.x.y.z', singleCharCerts),
|
||||
false,
|
||||
'Should NOT match multi-level subdomain of single char domain'
|
||||
);
|
||||
|
||||
// Test case 8: Domains with numbers and hyphens
|
||||
const numericCerts = new Map([
|
||||
['api-v2.service-1.com', { exists: true, wildcard: true }],
|
||||
['123.456.net', { exists: true, wildcard: true }]
|
||||
]);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('staging.api-v2.service-1.com', numericCerts),
|
||||
true,
|
||||
'Should match subdomain with hyphens and numbers'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('test.123.456.net', numericCerts),
|
||||
true,
|
||||
'Should match subdomain with numeric components'
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
isDomainCoveredByWildcard('deep.staging.api-v2.service-1.com', numericCerts),
|
||||
false,
|
||||
'Should NOT match multi-level subdomain with hyphens and numbers'
|
||||
);
|
||||
|
||||
console.log('All wildcard domain coverage tests passed!');
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user