♻️ Add ssl field to redirects and add traefik config to public file

This commit is contained in:
Fred KISSIE
2026-09-18 23:38:46 +02:00
parent d06a261cd6
commit 57b80bf74e
12 changed files with 200 additions and 14 deletions
+3 -1
View File
@@ -4452,5 +4452,7 @@
"redirectDomainRequired": "Select a domain to attach this redirect to", "redirectDomainRequired": "Select a domain to attach this redirect to",
"redirectResourceRequired": "Select a resource to attach this redirect to", "redirectResourceRequired": "Select a resource to attach this redirect to",
"redirectPermanent": "Permanent Redirect", "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."
} }
+3
View File
@@ -256,6 +256,9 @@ export const redirects = pgTable("redirects", {
>(), // exact, prefix, regex, stripPrefix >(), // exact, prefix, regex, stripPrefix
priority: integer("priority").default(100), priority: integer("priority").default(100),
permanent: boolean("permanent").notNull().default(false), 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) enabled: boolean("enabled").notNull().default(true)
}); });
+3
View File
@@ -274,6 +274,9 @@ export const redirects = sqliteTable("redirects", {
permanent: integer("permanent", { mode: "boolean" }) permanent: integer("permanent", { mode: "boolean" })
.notNull() .notNull()
.default(false), .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) enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
}); });
+93 -1
View File
@@ -2,6 +2,7 @@ import {
db, db,
domains, domains,
exitNodes, exitNodes,
redirects,
resources, resources,
siteNetworks, siteNetworks,
siteResources, siteResources,
@@ -45,6 +46,7 @@ import {
buildTcpUdpLoadBalancerServers buildTcpUdpLoadBalancerServers
} from "./loadBalancer"; } from "./loadBalancer";
import { applyPathRewriteMiddleware } from "./middleware"; import { applyPathRewriteMiddleware } from "./middleware";
import { buildRedirectConfig, RedirectRouteRow } from "./redirect";
import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule"; import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule";
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias"; import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
import { TargetWithSite } from "./types"; 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 // make sure we have at least one resource
if ( if (
resourcesMap.size === 0 && resourcesMap.size === 0 &&
inferenceResources.length === 0 && inferenceResources.length === 0 &&
browserGatewayResourcesMap.size === 0 && browserGatewayResourcesMap.size === 0 &&
siteResourcesWithFullDomain.length === 0 siteResourcesWithFullDomain.length === 0 &&
redirectRoutes.length === 0
) { ) {
return {}; 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) { if (browserGatewayUiUrl) {
buildBrowserGatewayConfig({ buildBrowserGatewayConfig({
config_output, config_output,
+2 -10
View File
@@ -414,6 +414,7 @@ export async function getTraefikConfig(
matchPath: redirects.matchPath, matchPath: redirects.matchPath,
pathMatchType: redirects.pathMatchType, pathMatchType: redirects.pathMatchType,
priority: redirects.priority, priority: redirects.priority,
ssl: redirects.ssl,
// Resource (when attached to one) // Resource (when attached to one)
resourceId: resources.resourceId, resourceId: resources.resourceId,
resourceFullDomain: resources.fullDomain, resourceFullDomain: resources.fullDomain,
@@ -473,8 +474,7 @@ export async function getTraefikConfig(
? !!row.resourceSubdomain ? !!row.resourceSubdomain
: !!row.subdomain, : !!row.subdomain,
wildcard: row.resourceWildcard, wildcard: row.resourceWildcard,
// Domain-attached redirects always get a certificate on creation ssl: attachedToResource ? !!row.resourceSsl : row.ssl,
ssl: attachedToResource ? !!row.resourceSsl : true,
attachedTo: attachedToResource ? "resource" : "domain", attachedTo: attachedToResource ? "resource" : "domain",
matchPath: row.matchPath, matchPath: row.matchPath,
pathMatchType: row.pathMatchType, 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 // Pangolin-managed DNS-01/ACME cert mode requires either a tier1
// license (self-hosted) or a saas build - otherwise fall back to // license (self-hosted) or a saas build - otherwise fall back to
// Traefik's own cert resolvers (buildWildcardTls) throughout. // Traefik's own cert resolvers (buildWildcardTls) throughout.
+1 -1
View File
@@ -1091,7 +1091,7 @@ function buildRedirectUrl(
function redirected(res: Response, redirectUrl: string, permanent: boolean) { function redirected(res: Response, redirectUrl: string, permanent: boolean) {
const data = { const data = {
data: { data: {
valid: false, valid: true,
redirectUrl, redirectUrl,
redirectPermanent: permanent, redirectPermanent: permanent,
pangolinVersion: APP_VERSION pangolinVersion: APP_VERSION
+10
View File
@@ -15,6 +15,7 @@ import {
redirectPathMatchTypeSchema, redirectPathMatchTypeSchema,
redirectRewritePathSchema, redirectRewritePathSchema,
isValidMatchPath, isValidMatchPath,
isAllowedSsl,
redirectPrioritySchema, redirectPrioritySchema,
redirectRewritePathTypeSchema redirectRewritePathTypeSchema
} from "@server/routers/redirect/validation"; } from "@server/routers/redirect/validation";
@@ -42,6 +43,7 @@ const bodySchema = z
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
priority: redirectPrioritySchema.optional().nullable(), priority: redirectPrioritySchema.optional().nullable(),
permanent: z.boolean().optional(), permanent: z.boolean().optional(),
ssl: z.boolean().optional(),
enabled: z.boolean().optional() enabled: z.boolean().optional()
}) })
.refine( .refine(
@@ -64,6 +66,10 @@ const bodySchema = z
.refine((data) => isValidMatchPath(data.matchPath, data.pathMatchType), { .refine((data) => isValidMatchPath(data.matchPath, data.pathMatchType), {
message: "matchPath must be a valid regular expression", message: "matchPath must be a valid regular expression",
path: ["matchPath"] path: ["matchPath"]
})
.refine((data) => isAllowedSsl(data.ssl), {
message: "TLS cannot be disabled on this build",
path: ["ssl"]
}); });
registry.registerPath({ registry.registerPath({
@@ -127,6 +133,7 @@ export async function createRedirect(
rewritePathType, rewritePathType,
priority, priority,
permanent, permanent,
ssl,
enabled enabled
} = parsedBody.data; } = parsedBody.data;
@@ -200,6 +207,9 @@ export async function createRedirect(
rewritePathType: rewritePathType ?? null, rewritePathType: rewritePathType ?? null,
priority: priority ?? 100, priority: priority ?? 100,
permanent: permanent ?? false, 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 enabled: enabled ?? true
}) })
.returning(); .returning();
+2
View File
@@ -24,6 +24,7 @@ export type GetRedirectResponse = {
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
priority: number | null; priority: number | null;
permanent: boolean; permanent: boolean;
ssl: boolean;
enabled: boolean; enabled: boolean;
resourceId: number | null; resourceId: number | null;
resourceName: string | null; resourceName: string | null;
@@ -49,6 +50,7 @@ const redirectColumns = {
rewritePathType: redirects.rewritePathType, rewritePathType: redirects.rewritePathType,
priority: redirects.priority, priority: redirects.priority,
permanent: redirects.permanent, permanent: redirects.permanent,
ssl: redirects.ssl,
enabled: redirects.enabled, enabled: redirects.enabled,
resourceId: redirects.resourceId, resourceId: redirects.resourceId,
resourceName: resources.name, resourceName: resources.name,
+2
View File
@@ -24,6 +24,7 @@ export type ListRedirectsResponse = PaginatedResponse<{
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null; rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
priority: number | null; priority: number | null;
permanent: boolean; permanent: boolean;
ssl: boolean;
enabled: boolean; enabled: boolean;
resourceId: number | null; resourceId: number | null;
resourceName: string | null; resourceName: string | null;
@@ -148,6 +149,7 @@ export async function listRedirects(
rewritePathType: redirects.rewritePathType, rewritePathType: redirects.rewritePathType,
priority: redirects.priority, priority: redirects.priority,
permanent: redirects.permanent, permanent: redirects.permanent,
ssl: redirects.ssl,
enabled: redirects.enabled, enabled: redirects.enabled,
resourceId: redirects.resourceId, resourceId: redirects.resourceId,
resourceName: resources.name, resourceName: resources.name,
+19 -1
View File
@@ -17,7 +17,8 @@ import {
redirectRewritePathSchema, redirectRewritePathSchema,
redirectRewritePathTypeSchema, redirectRewritePathTypeSchema,
redirectPrioritySchema, redirectPrioritySchema,
isValidMatchPath isValidMatchPath,
isAllowedSsl
} from "@server/routers/redirect/validation"; } from "@server/routers/redirect/validation";
import { createCertificate } from "../certificates"; import { createCertificate } from "../certificates";
@@ -43,6 +44,7 @@ const bodySchema = z.strictObject({
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(), rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
priority: redirectPrioritySchema.optional(), priority: redirectPrioritySchema.optional(),
permanent: z.boolean().optional(), permanent: z.boolean().optional(),
ssl: z.boolean().optional(),
enabled: 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<typeof redirects.$inferInsert> = {}; const updateData: Partial<typeof redirects.$inferInsert> = {};
if (body.name !== undefined) { if (body.name !== undefined) {
@@ -263,6 +274,13 @@ export async function updateRedirect(
if (body.permanent !== undefined) { if (body.permanent !== undefined) {
updateData.permanent = body.permanent; 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) { if (body.enabled !== undefined) {
updateData.enabled = body.enabled; updateData.enabled = body.enabled;
} }
+10
View File
@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { isValidDomain } from "@server/lib/validators"; import { isValidDomain } from "@server/lib/validators";
import { build } from "@server/build";
export const redirectNiceIdSchema = z export const redirectNiceIdSchema = z
.string() .string()
@@ -54,3 +55,12 @@ export const redirectDestinationDomainSchema = z
.refine(isValidDomain, { .refine(isValidDomain, {
message: "Invalid domain" 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;
}
+52
View File
@@ -42,6 +42,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { isValidDomain } from "@server/lib/validators"; import { isValidDomain } from "@server/lib/validators";
import { isValidRegex } from "@server/routers/redirect/validation"; import { isValidRegex } from "@server/routers/redirect/validation";
import { build } from "@server/build";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
@@ -139,6 +140,7 @@ export default function RedirectForm({
.min(1, { message: t("redirectPriorityInvalid") }) .min(1, { message: t("redirectPriorityInvalid") })
.max(1000, { message: t("redirectPriorityInvalid") }), .max(1000, { message: t("redirectPriorityInvalid") }),
permanent: z.boolean(), permanent: z.boolean(),
ssl: z.boolean(),
enabled: z.boolean() enabled: z.boolean()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
@@ -201,6 +203,7 @@ export default function RedirectForm({
rewritePathType: redirect?.rewritePathType ?? null, rewritePathType: redirect?.rewritePathType ?? null,
priority: redirect?.priority ?? DEFAULT_PRIORITY, priority: redirect?.priority ?? DEFAULT_PRIORITY,
permanent: redirect?.permanent ?? false, permanent: redirect?.permanent ?? false,
ssl: redirect?.ssl ?? true,
enabled: redirect?.enabled ?? true enabled: redirect?.enabled ?? true
} }
}); });
@@ -257,6 +260,8 @@ export default function RedirectForm({
rewritePathType: values.rewritePathType, rewritePathType: values.rewritePathType,
priority: values.priority, priority: values.priority,
permanent: values.permanent, permanent: values.permanent,
// Resource-attached redirects inherit the resource's ssl setting
ssl: values.attachTo === "domain" ? values.ssl : true,
enabled: values.enabled enabled: values.enabled
}; };
@@ -581,6 +586,53 @@ export default function RedirectForm({
</FormItem> </FormItem>
</SettingsFormCell> </SettingsFormCell>
)} )}
{/* The cloud only serves HTTPS, so there is nothing to toggle there. */}
{build !== "saas" && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="redirect-ssl"
label={t(
"proxyEnableSSL"
)}
description={
attachTo ===
"resource"
? t(
"redirectSslInheritedDescription"
)
: t(
"redirectSslDescription"
)
}
disabled={
attachTo ===
"resource"
}
checked={
attachTo ===
"resource"
? (selectedResource?.ssl ??
true)
: field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid> </SettingsFormGrid>
</form> </form>
</Form> </Form>