From c51bcbb5786ef6b3fc9dd0c4a437cbf024c604ef Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 16 Sep 2026 19:30:10 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20traefik=20config=20for=20?= =?UTF-8?q?redirect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/schema.ts | 2 +- server/lib/traefik/getTraefikConfig.ts | 19 ++- server/lib/traefik/redirect.ts | 121 ++++++++++++++++++ .../private/lib/traefik/getTraefikConfig.ts | 114 +++++++++++++++++ 4 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 server/lib/traefik/redirect.ts diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index e3517a533..4540546be 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -254,7 +254,7 @@ export const redirects = pgTable("redirects", { rewritePathType: varchar("rewritePathType").$type< "exact" | "prefix" | "regex" | "stripPrefix" >(), // exact, prefix, regex, stripPrefix - + priority: integer("priority").default(100), permanent: boolean("permanent").notNull().default(false), enabled: boolean("enabled").notNull().default(true) }); diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index ae8554597..5b3392368 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -6,7 +6,8 @@ import { resourceAiProviders, siteResources, siteNetworks, - exitNodes + exitNodes, + redirects } from "@server/db"; import { and, @@ -128,14 +129,21 @@ export async function getTraefikConfig( siteOnline: sites.online, subnet: sites.exitNodeSubnet, exitNodeId: sites.exitNodeId, + // Domain cert resolver fields domainCertResolver: domains.certResolver, - preferWildcardCert: domains.preferWildcardCert + preferWildcardCert: domains.preferWildcardCert, + + // redirects + redirectMatchPath: redirects.matchPath, + redirectPathMatchType: redirects.pathMatchType, + redirectPriority: redirects.priority }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) .leftJoin(domains, eq(domains.domainId, resources.domainId)) + .leftJoin(redirects, eq(resources.resourceId, redirects.resourceId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -167,6 +175,13 @@ export async function getTraefikConfig( ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering + console.dir( + { + resourcesWithTargetsAndSites + }, + { depth: null } + ); + // Group by resource and include targets with their unique site data const resourcesMap = new Map(); diff --git a/server/lib/traefik/redirect.ts b/server/lib/traefik/redirect.ts new file mode 100644 index 000000000..7af82549a --- /dev/null +++ b/server/lib/traefik/redirect.ts @@ -0,0 +1,121 @@ +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { + buildHostRule, + appendPathMatch, + computeRoutePriority +} from "@server/lib/traefik/rule"; + +export type RedirectRouteRow = { + redirectId: number; + /** Host the redirect listens on (resource fullDomain or subdomain.baseDomain). */ + fullDomain: string; + hasSubdomain: boolean; + wildcard: boolean | null; + ssl: boolean; + matchPath: string; + pathMatchType: string; + priority: number | null; + domainCertResolver?: string | null; + preferWildcardCert?: boolean | null; +}; + +/** + * Add Traefik routers for redirects. Like resources, every request is sent + * through badger, which looks up the redirect by host/path, applies any + * path rewrite and answers with the redirect itself - Traefik only has to + * match the host (+ path) and terminate TLS. Redirects have no backend, so + * the routers point at Traefik's built-in noop@internal service. + * TLS/cert-resolver handling differs between the OSS and private + * (pangolin-dns aware) config generators, so callers resolve that via + * resolveTls - returning null skips the redirect (no valid cert yet). + */ +export function buildRedirectConfig(params: { + config_output: any; + redirects: RedirectRouteRow[]; + badgerMiddlewareName: string; + redirectHttpsMiddlewareName: string; + resolveTls: (row: RedirectRouteRow) => any | null; +}): void { + const { + config_output, + redirects, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls + } = params; + + if (redirects.length === 0) { + return; + } + + const httpEntrypoint = config.getRawConfig().traefik.http_entrypoint; + const httpsEntrypoint = config.getRawConfig().traefik.https_entrypoint; + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + const routerMiddlewares = [badgerMiddlewareName, ...additionalMiddlewares]; + + for (const redirect of redirects) { + const routerName = `redirect-${redirect.redirectId}-router`; + + let tls: any = {}; + if (redirect.ssl) { + tls = resolveTls(redirect); + if (tls === null) { + continue; + } + } + + if (!config_output.http.routers) { + config_output.http.routers = {}; + } + + if (redirect.pathMatchType === "regex") { + try { + new RegExp(redirect.matchPath); + } catch { + logger.debug( + `Invalid regex pattern in redirect ${redirect.redirectId} match path: ${redirect.matchPath}` + ); + continue; + } + } + + const rule = appendPathMatch( + buildHostRule(redirect.fullDomain, redirect.wildcard), + redirect.matchPath, + redirect.pathMatchType + ); + + // A redirect attached to a resource must win over that resource's + // router at the same host/path specificity, so nudge derived + // priorities up by one. Explicit priorities are used as-is. + const hasExplicitPriority = + !!redirect.priority && redirect.priority !== 100; + const priority = + computeRoutePriority( + redirect.priority, + redirect.matchPath, + redirect.pathMatchType + ) + (hasExplicitPriority ? 0 : 1); + + if (redirect.ssl) { + config_output.http.routers[`${routerName}-redirect`] = { + entryPoints: [httpEntrypoint], + middlewares: [redirectHttpsMiddlewareName], + service: "noop@internal", + rule, + priority + }; + } + + config_output.http.routers[routerName] = { + entryPoints: [redirect.ssl ? httpsEntrypoint : httpEntrypoint], + middlewares: routerMiddlewares, + service: "noop@internal", + rule, + priority, + ...(redirect.ssl ? { tls } : {}) + }; + } +} diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 5fc1304a2..828b3bda2 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -18,6 +18,7 @@ import { domains, exitNodes, loginPage, + redirects, SiteResource, targetHealthCheck } from "@server/db"; @@ -84,6 +85,10 @@ import { buildBrowserGatewayConfig } from "@server/lib/traefik/browserGateway"; import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias"; +import { + buildRedirectConfig, + RedirectRouteRow +} from "@server/lib/traefik/redirect"; const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; @@ -395,6 +400,81 @@ export async function getTraefikConfig( ) ); + // Redirects have no targets/sites, so like inference resources they are + // queried separately and emitted on every exit node. A redirect listens + // either on a resource's fullDomain or on subdomain.baseDomain of a + // domain; the domain join resolves to whichever one applies. + const redirectRows = await db + .select({ + redirectId: redirects.redirectId, + subdomain: redirects.subdomain, + matchPath: redirects.matchPath, + pathMatchType: redirects.pathMatchType, + priority: redirects.priority, + // Resource (when attached to one) + resourceId: resources.resourceId, + resourceFullDomain: resources.fullDomain, + resourceSubdomain: resources.subdomain, + resourceSsl: resources.ssl, + resourceWildcard: resources.wildcard, + // Domain (the redirect's own, or the resource's) + baseDomain: domains.baseDomain, + domainCertResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert, + domainNamespaceId: domainNamespaces.domainNamespaceId + }) + .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin( + domains, + eq( + domains.domainId, + sql`coalesce(${redirects.domainId}, ${resources.domainId})` + ) + ) + .leftJoin(domainNamespaces, eq(domainNamespaces.domainId, domains.domainId)) + .where( + and( + eq(redirects.enabled, true), + or(isNull(redirects.resourceId), eq(resources.enabled, true)) + ) + ) + .orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering + + const redirectRoutes: RedirectRouteRow[] = []; + for (const row of redirectRows) { + if (filterOutNamespaceDomains && row.domainNamespaceId) { + continue; + } + + const attachedToResource = row.resourceId !== null; + const fullDomain = attachedToResource + ? row.resourceFullDomain + : [row.subdomain, row.baseDomain].filter(Boolean).join("."); + if (!fullDomain) { + logger.debug( + `Redirect ${row.redirectId} has no host to listen on, skipping Traefik config` + ); + continue; + } + + redirectRoutes.push({ + redirectId: row.redirectId, + fullDomain, + hasSubdomain: attachedToResource + ? !!row.resourceSubdomain + : !!row.subdomain, + wildcard: row.resourceWildcard, + // Domain-attached redirects always get a certificate on creation + ssl: attachedToResource ? !!row.resourceSsl : true, + matchPath: row.matchPath, + pathMatchType: row.pathMatchType, + priority: row.priority, + domainCertResolver: row.domainCertResolver, + preferWildcardCert: row.preferWildcardCert + }); + } + let validCerts: CertificateResult[] = []; if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { // create a list of all domains to get certs for @@ -427,6 +507,12 @@ export async function getTraefikConfig( domains.add(sr.fullDomain); } } + // Include redirect hosts + for (const redirect of redirectRoutes) { + if (redirect.ssl) { + domains.add(redirect.fullDomain); + } + } // get the valid certs for these domains validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); @@ -779,6 +865,34 @@ export async function getTraefikConfig( } } + buildRedirectConfig({ + config_output, + redirects: redirectRoutes, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls: (redirect) => { + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { + return buildWildcardTls({ + fullDomain: redirect.fullDomain, + hasSubdomain: redirect.hasSubdomain, + domainCertResolver: redirect.domainCertResolver, + preferWildcardCert: + redirect.preferWildcardCert || redirect.wildcard + }); + } + const matchingCert = validCerts.find( + (cert) => cert.queriedDomain === redirect.fullDomain + ); + if (!matchingCert) { + logger.debug( + `No matching certificate found for redirect domain: ${redirect.fullDomain}` + ); + return null; + } + return {}; + } + }); + if (browserGatewayUiUrl) { buildBrowserGatewayConfig({ config_output,