diff --git a/messages/en-US.json b/messages/en-US.json index e7bc42222..b41d06eb6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4452,5 +4452,7 @@ "redirectDomainRequired": "Select a domain to attach this redirect to", "redirectResourceRequired": "Select a resource to attach this redirect to", "redirectPermanent": "Permanent Redirect", - "redirectPermanentDescription": "Respond with 308 instead of 307. Permanent redirects are cached by browsers." + "redirectPermanentDescription": "Respond with 308 instead of 307. Permanent redirects are cached by browsers.", + "redirectSslDescription": "Serve this redirect over HTTPS. Turn off to match requests on plain HTTP.", + "redirectSslInheritedDescription": "Inherited from the attached resource's TLS setting." } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 39be6213a..cdf6e9f29 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -256,6 +256,9 @@ export const redirects = pgTable("redirects", { >(), // exact, prefix, regex, stripPrefix priority: integer("priority").default(100), permanent: boolean("permanent").notNull().default(false), + // Only consulted for domain-attached redirects; resource-attached ones + // inherit the resource's ssl setting. + ssl: boolean("ssl").notNull().default(true), enabled: boolean("enabled").notNull().default(true) }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e39e9508e..0fff3b9f8 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -274,6 +274,9 @@ export const redirects = sqliteTable("redirects", { permanent: integer("permanent", { mode: "boolean" }) .notNull() .default(false), + // Only consulted for domain-attached redirects; resource-attached ones + // inherit the resource's ssl setting. + ssl: integer("ssl", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) }); diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 726d93ab4..3401a797b 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -2,6 +2,7 @@ import { db, domains, exitNodes, + redirects, resources, siteNetworks, siteResources, @@ -45,6 +46,7 @@ import { buildTcpUdpLoadBalancerServers } from "./loadBalancer"; import { applyPathRewriteMiddleware } from "./middleware"; +import { buildRedirectConfig, RedirectRouteRow } from "./redirect"; import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule"; import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias"; import { TargetWithSite } from "./types"; @@ -326,12 +328,87 @@ 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({ + name: redirects.name, + enabled: redirects.enabled, + redirectId: redirects.redirectId, + subdomain: redirects.subdomain, + matchPath: redirects.matchPath, + pathMatchType: redirects.pathMatchType, + priority: redirects.priority, + ssl: redirects.ssl, + // 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 + }) + .from(redirects) + .leftJoin(resources, eq(resources.resourceId, redirects.resourceId)) + .leftJoin( + domains, + eq( + domains.domainId, + sql`coalesce(${redirects.domainId}, ${resources.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) { + 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({ + enabled: row.enabled, + name: sanitize(row.name) || "", + redirectId: row.redirectId, + fullDomain, + hasSubdomain: attachedToResource + ? !!row.resourceSubdomain + : !!row.subdomain, + wildcard: row.resourceWildcard, + ssl: attachedToResource ? !!row.resourceSsl : row.ssl, + attachedTo: attachedToResource ? "resource" : "domain", + matchPath: row.matchPath, + pathMatchType: row.pathMatchType, + priority: row.priority, + domainCertResolver: row.domainCertResolver, + preferWildcardCert: row.preferWildcardCert + }); + } + // make sure we have at least one resource if ( resourcesMap.size === 0 && inferenceResources.length === 0 && browserGatewayResourcesMap.size === 0 && - siteResourcesWithFullDomain.length === 0 + siteResourcesWithFullDomain.length === 0 && + redirectRoutes.length === 0 ) { return {}; } @@ -518,6 +595,21 @@ export async function getTraefikConfig( } } + buildRedirectConfig({ + config_output, + redirects: redirectRoutes, + badgerMiddlewareName, + redirectHttpsMiddlewareName, + resolveTls: (redirect) => + buildWildcardTls({ + fullDomain: redirect.fullDomain, + hasSubdomain: redirect.hasSubdomain, + domainCertResolver: redirect.domainCertResolver, + preferWildcardCert: + redirect.preferWildcardCert || redirect.wildcard + }) + }); + if (browserGatewayUiUrl) { buildBrowserGatewayConfig({ config_output, diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 8743c93e5..6b8e0b97d 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -414,6 +414,7 @@ export async function getTraefikConfig( matchPath: redirects.matchPath, pathMatchType: redirects.pathMatchType, priority: redirects.priority, + ssl: redirects.ssl, // Resource (when attached to one) resourceId: resources.resourceId, resourceFullDomain: resources.fullDomain, @@ -473,8 +474,7 @@ export async function getTraefikConfig( ? !!row.resourceSubdomain : !!row.subdomain, wildcard: row.resourceWildcard, - // Domain-attached redirects always get a certificate on creation - ssl: attachedToResource ? !!row.resourceSsl : true, + ssl: attachedToResource ? !!row.resourceSsl : row.ssl, attachedTo: attachedToResource ? "resource" : "domain", matchPath: row.matchPath, pathMatchType: row.pathMatchType, @@ -484,14 +484,6 @@ export async function getTraefikConfig( }); } - console.dir( - { - redirectRoutes, - redirectRows - }, - { depth: null } - ); - // Pangolin-managed DNS-01/ACME cert mode requires either a tier1 // license (self-hosted) or a saas build - otherwise fall back to // Traefik's own cert resolvers (buildWildcardTls) throughout. diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index da3b18685..6c6f36342 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1091,7 +1091,7 @@ function buildRedirectUrl( function redirected(res: Response, redirectUrl: string, permanent: boolean) { const data = { data: { - valid: false, + valid: true, redirectUrl, redirectPermanent: permanent, pangolinVersion: APP_VERSION diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index 8167428ef..bbcb3ac56 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -15,6 +15,7 @@ import { redirectPathMatchTypeSchema, redirectRewritePathSchema, isValidMatchPath, + isAllowedSsl, redirectPrioritySchema, redirectRewritePathTypeSchema } from "@server/routers/redirect/validation"; @@ -42,6 +43,7 @@ const bodySchema = z rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), priority: redirectPrioritySchema.optional().nullable(), permanent: z.boolean().optional(), + ssl: z.boolean().optional(), enabled: z.boolean().optional() }) .refine( @@ -64,6 +66,10 @@ const bodySchema = z .refine((data) => isValidMatchPath(data.matchPath, data.pathMatchType), { message: "matchPath must be a valid regular expression", path: ["matchPath"] + }) + .refine((data) => isAllowedSsl(data.ssl), { + message: "TLS cannot be disabled on this build", + path: ["ssl"] }); registry.registerPath({ @@ -127,6 +133,7 @@ export async function createRedirect( rewritePathType, priority, permanent, + ssl, enabled } = parsedBody.data; @@ -200,6 +207,9 @@ export async function createRedirect( rewritePathType: rewritePathType ?? null, priority: priority ?? 100, permanent: permanent ?? false, + // Resource-attached redirects follow the resource's ssl, so + // the column is only meaningful for domain-attached ones. + ssl: resource ? true : (ssl ?? true), enabled: enabled ?? true }) .returning(); diff --git a/server/routers/redirect/getRedirect.ts b/server/routers/redirect/getRedirect.ts index 6cdea3d6f..a7454f7ca 100644 --- a/server/routers/redirect/getRedirect.ts +++ b/server/routers/redirect/getRedirect.ts @@ -24,6 +24,7 @@ export type GetRedirectResponse = { rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; priority: number | null; permanent: boolean; + ssl: boolean; enabled: boolean; resourceId: number | null; resourceName: string | null; @@ -49,6 +50,7 @@ const redirectColumns = { rewritePathType: redirects.rewritePathType, priority: redirects.priority, permanent: redirects.permanent, + ssl: redirects.ssl, enabled: redirects.enabled, resourceId: redirects.resourceId, resourceName: resources.name, diff --git a/server/routers/redirect/listRedirects.ts b/server/routers/redirect/listRedirects.ts index c4421c0f6..229e2bb66 100644 --- a/server/routers/redirect/listRedirects.ts +++ b/server/routers/redirect/listRedirects.ts @@ -24,6 +24,7 @@ export type ListRedirectsResponse = PaginatedResponse<{ rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; priority: number | null; permanent: boolean; + ssl: boolean; enabled: boolean; resourceId: number | null; resourceName: string | null; @@ -148,6 +149,7 @@ export async function listRedirects( rewritePathType: redirects.rewritePathType, priority: redirects.priority, permanent: redirects.permanent, + ssl: redirects.ssl, enabled: redirects.enabled, resourceId: redirects.resourceId, resourceName: resources.name, diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 2833ebd70..84afad2e5 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -17,7 +17,8 @@ import { redirectRewritePathSchema, redirectRewritePathTypeSchema, redirectPrioritySchema, - isValidMatchPath + isValidMatchPath, + isAllowedSsl } from "@server/routers/redirect/validation"; import { createCertificate } from "../certificates"; @@ -43,6 +44,7 @@ const bodySchema = z.strictObject({ rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), priority: redirectPrioritySchema.optional(), permanent: z.boolean().optional(), + ssl: z.boolean().optional(), enabled: z.boolean().optional() }); @@ -225,6 +227,15 @@ export async function updateRedirect( ); } + if (!isAllowedSsl(body.ssl)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "TLS cannot be disabled on this build" + ) + ); + } + const updateData: Partial = {}; if (body.name !== undefined) { @@ -263,6 +274,13 @@ export async function updateRedirect( if (body.permanent !== undefined) { updateData.permanent = body.permanent; } + if (effectiveResourceId) { + // Resource-attached redirects follow the resource's ssl; reset + // the column so a later move back to a domain starts from TLS on. + updateData.ssl = true; + } else if (body.ssl !== undefined) { + updateData.ssl = body.ssl; + } if (body.enabled !== undefined) { updateData.enabled = body.enabled; } diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index a72910a04..3f2d2e087 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { isValidDomain } from "@server/lib/validators"; +import { build } from "@server/build"; export const redirectNiceIdSchema = z .string() @@ -54,3 +55,12 @@ export const redirectDestinationDomainSchema = z .refine(isValidDomain, { message: "Invalid domain" }); + +/** + * The cloud only serves HTTPS, so a domain-attached redirect may not opt out + * of TLS there. Resource-attached redirects inherit the resource's ssl + * setting and never carry their own. + */ +export function isAllowedSsl(ssl: boolean | undefined): boolean { + return build !== "saas" || ssl !== false; +} diff --git a/src/components/RedirectForm.tsx b/src/components/RedirectForm.tsx index 9d0ec7211..a1c0a52bf 100644 --- a/src/components/RedirectForm.tsx +++ b/src/components/RedirectForm.tsx @@ -42,6 +42,7 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { isValidDomain } from "@server/lib/validators"; import { isValidRegex } from "@server/routers/redirect/validation"; +import { build } from "@server/build"; import { cn } from "@app/lib/cn"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -139,6 +140,7 @@ export default function RedirectForm({ .min(1, { message: t("redirectPriorityInvalid") }) .max(1000, { message: t("redirectPriorityInvalid") }), permanent: z.boolean(), + ssl: z.boolean(), enabled: z.boolean() }) .superRefine((data, ctx) => { @@ -201,6 +203,7 @@ export default function RedirectForm({ rewritePathType: redirect?.rewritePathType ?? null, priority: redirect?.priority ?? DEFAULT_PRIORITY, permanent: redirect?.permanent ?? false, + ssl: redirect?.ssl ?? true, enabled: redirect?.enabled ?? true } }); @@ -257,6 +260,8 @@ export default function RedirectForm({ rewritePathType: values.rewritePathType, priority: values.priority, permanent: values.permanent, + // Resource-attached redirects inherit the resource's ssl setting + ssl: values.attachTo === "domain" ? values.ssl : true, enabled: values.enabled }; @@ -581,6 +586,53 @@ export default function RedirectForm({ )} + + {/* The cloud only serves HTTPS, so there is nothing to toggle there. */} + {build !== "saas" && ( + + ( + + + + + + + )} + /> + + )}