🚧 wip: traefik config for redirect

This commit is contained in:
Fred KISSIE
2026-09-16 19:30:10 +02:00
parent a0c77a4c85
commit c51bcbb578
4 changed files with 253 additions and 3 deletions
+1 -1
View File
@@ -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)
});
+17 -2
View File
@@ -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();
+121
View File
@@ -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 } : {})
};
}
}
@@ -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,