mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-17 16:19:48 +02:00
🚧 wip: traefik config for redirect
This commit is contained in:
@@ -254,7 +254,7 @@ export const redirects = pgTable("redirects", {
|
|||||||
rewritePathType: varchar("rewritePathType").$type<
|
rewritePathType: varchar("rewritePathType").$type<
|
||||||
"exact" | "prefix" | "regex" | "stripPrefix"
|
"exact" | "prefix" | "regex" | "stripPrefix"
|
||||||
>(), // exact, prefix, regex, stripPrefix
|
>(), // exact, prefix, regex, stripPrefix
|
||||||
|
priority: integer("priority").default(100),
|
||||||
permanent: boolean("permanent").notNull().default(false),
|
permanent: boolean("permanent").notNull().default(false),
|
||||||
enabled: boolean("enabled").notNull().default(true)
|
enabled: boolean("enabled").notNull().default(true)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
resourceAiProviders,
|
resourceAiProviders,
|
||||||
siteResources,
|
siteResources,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
exitNodes
|
exitNodes,
|
||||||
|
redirects
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
@@ -128,14 +129,21 @@ export async function getTraefikConfig(
|
|||||||
siteOnline: sites.online,
|
siteOnline: sites.online,
|
||||||
subnet: sites.exitNodeSubnet,
|
subnet: sites.exitNodeSubnet,
|
||||||
exitNodeId: sites.exitNodeId,
|
exitNodeId: sites.exitNodeId,
|
||||||
|
|
||||||
// Domain cert resolver fields
|
// Domain cert resolver fields
|
||||||
domainCertResolver: domains.certResolver,
|
domainCertResolver: domains.certResolver,
|
||||||
preferWildcardCert: domains.preferWildcardCert
|
preferWildcardCert: domains.preferWildcardCert,
|
||||||
|
|
||||||
|
// redirects
|
||||||
|
redirectMatchPath: redirects.matchPath,
|
||||||
|
redirectPathMatchType: redirects.pathMatchType,
|
||||||
|
redirectPriority: redirects.priority
|
||||||
})
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
.innerJoin(targets, eq(targets.siteId, sites.siteId))
|
||||||
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
.innerJoin(resources, eq(resources.resourceId, targets.resourceId))
|
||||||
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||||
|
.leftJoin(redirects, eq(resources.resourceId, redirects.resourceId))
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
eq(targetHealthCheck.targetId, targets.targetId)
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
@@ -167,6 +175,13 @@ export async function getTraefikConfig(
|
|||||||
)
|
)
|
||||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||||
|
|
||||||
|
console.dir(
|
||||||
|
{
|
||||||
|
resourcesWithTargetsAndSites
|
||||||
|
},
|
||||||
|
{ depth: null }
|
||||||
|
);
|
||||||
|
|
||||||
// Group by resource and include targets with their unique site data
|
// Group by resource and include targets with their unique site data
|
||||||
const resourcesMap = new Map();
|
const resourcesMap = new Map();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
loginPage,
|
loginPage,
|
||||||
|
redirects,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
targetHealthCheck
|
targetHealthCheck
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
@@ -84,6 +85,10 @@ import {
|
|||||||
buildBrowserGatewayConfig
|
buildBrowserGatewayConfig
|
||||||
} from "@server/lib/traefik/browserGateway";
|
} from "@server/lib/traefik/browserGateway";
|
||||||
import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias";
|
import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias";
|
||||||
|
import {
|
||||||
|
buildRedirectConfig,
|
||||||
|
RedirectRouteRow
|
||||||
|
} from "@server/lib/traefik/redirect";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const redirectToRootMiddlewareName = "redirect-to-root";
|
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[] = [];
|
let validCerts: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
// create a list of all domains to get certs for
|
// create a list of all domains to get certs for
|
||||||
@@ -427,6 +507,12 @@ export async function getTraefikConfig(
|
|||||||
domains.add(sr.fullDomain);
|
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
|
// get the valid certs for these domains
|
||||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
// 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) {
|
if (browserGatewayUiUrl) {
|
||||||
buildBrowserGatewayConfig({
|
buildBrowserGatewayConfig({
|
||||||
config_output,
|
config_output,
|
||||||
|
|||||||
Reference in New Issue
Block a user