♻️ 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",
"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."
}
+3
View File
@@ -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)
});
+3
View File
@@ -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)
});
+93 -1
View File
@@ -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,
+2 -10
View File
@@ -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.
+1 -1
View File
@@ -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
+10
View File
@@ -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();
+2
View File
@@ -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,
+2
View File
@@ -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,
+19 -1
View File
@@ -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<typeof redirects.$inferInsert> = {};
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;
}
+10
View File
@@ -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;
}
+52
View File
@@ -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({
</FormItem>
</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>
</form>
</Form>