From 674316aa46014179e358cc39a9dc6b7cb81e7cb6 Mon Sep 17 00:00:00 2001 From: Matthias Palmetshofer Date: Wed, 9 Apr 2025 23:42:50 +0200 Subject: [PATCH 1/4] add option to set TLS Server Name --- server/db/schemas/schema.ts | 3 +- server/lib/schemas.ts | 7 +++ server/routers/resource/listResources.ts | 6 ++- server/routers/resource/updateResource.ts | 14 +++++- server/routers/traefik/getTraefikConfig.ts | 19 +++++++- .../resources/[resourceId]/general/page.tsx | 46 +++++++++++++++++-- 6 files changed, 84 insertions(+), 11 deletions(-) diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index a8627553..2fe5ac2b 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -77,7 +77,8 @@ export const resources = sqliteTable("resources", { applyRules: integer("applyRules", { mode: "boolean" }) .notNull() .default(false), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + tlsServerName: text("tlsServerName").notNull().default("") }); export const targets = sqliteTable("targets", { diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index f4b7daf3..cf1b40c8 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -9,3 +9,10 @@ export const subdomainSchema = z .min(1, "Subdomain must be at least 1 character long") .transform((val) => val.toLowerCase()); +export const tlsNameSchema = z + .string() + .regex( + /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/, + "Invalid subdomain format" + ) + .transform((val) => val.toLowerCase()); \ No newline at end of file diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 1dba4119..56df9128 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -68,7 +68,8 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled + enabled: resources.enabled, + tlsServerName: resources.tlsServerName }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) @@ -102,7 +103,8 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled + enabled: resources.enabled, + tlsServerName: resources.tlsServerName }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 121b34ed..54802ccc 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -16,7 +16,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import config from "@server/lib/config"; -import { subdomainSchema } from "@server/lib/schemas"; +import { subdomainSchema, tlsNameSchema } from "@server/lib/schemas"; const updateResourceParamsSchema = z .object({ @@ -40,7 +40,8 @@ const updateHttpResourceBodySchema = z isBaseDomain: z.boolean().optional(), applyRules: z.boolean().optional(), domainId: z.string().optional(), - enabled: z.boolean().optional() + enabled: z.boolean().optional(), + tlsServerName: z.string().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -67,6 +68,15 @@ const updateHttpResourceBodySchema = z { message: "Base domain resources are not allowed" } + ) + .refine( + (data) => { + if (data.tlsServerName) { + return tlsNameSchema.safeParse(data.tlsServerName).success; + } + return true; + }, + { message: "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name." } ); export type UpdateResourceResponse = Resource; diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 17e385ed..42a47940 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -40,7 +40,8 @@ export async function traefikConfigProvider( org: { orgId: orgs.orgId }, - enabled: resources.enabled + enabled: resources.enabled, + tlsServerName: resources.tlsServerName }) .from(resources) .innerJoin(sites, eq(sites.siteId, resources.siteId)) @@ -139,6 +140,7 @@ export async function traefikConfigProvider( const routerName = `${resource.resourceId}-router`; const serviceName = `${resource.resourceId}-service`; const fullDomain = `${resource.fullDomain}`; + const transportName = `${resource.resourceId}-transport`; if (!resource.enabled) { continue; @@ -278,6 +280,21 @@ export async function traefikConfigProvider( }) } }; + + // Add the serversTransport if TLS server name is provided + if (resource.tlsServerName) { + if (!config_output.http.serversTransports) { + config_output.http.serversTransports = {}; + } + config_output.http.serversTransports![transportName] = { + serverName: resource.tlsServerName, + //unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings + // if defined in the static config and here. if not set, self-signed certs won't work + insecureSkipVerify: true + }; + config_output.http.services![serviceName].loadBalancer.serversTransport = transportName; + } + } else { // Non-HTTP (TCP/UDP) configuration const protocol = resource.protocol.toLowerCase(); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 5d6cc81e..a3fccf26 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -48,7 +48,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import CustomDomainInput from "../CustomDomainInput"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { subdomainSchema } from "@server/lib/schemas"; +import { subdomainSchema, tlsNameSchema } from "@server/lib/schemas"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; @@ -73,7 +73,8 @@ const GeneralFormSchema = z proxyPort: z.number().optional(), http: z.boolean(), isBaseDomain: z.boolean().optional(), - domainId: z.string().optional() + domainId: z.string().optional(), + tlsServerName: z.string().optional() }) .refine( (data) => { @@ -103,6 +104,18 @@ const GeneralFormSchema = z message: "Invalid subdomain", path: ["subdomain"] } + ) + .refine( + (data) => { + if (data.tlsServerName) { + return tlsNameSchema.safeParse(data.tlsServerName).success; + } + return true; + }, + { + message: "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.", + path: ["tlsServerName"] + } ); const TransferFormSchema = z.object({ @@ -146,7 +159,8 @@ export default function GeneralForm() { proxyPort: resource.proxyPort ? resource.proxyPort : undefined, http: resource.http, isBaseDomain: resource.isBaseDomain ? true : false, - domainId: resource.domainId || undefined + domainId: resource.domainId || undefined, + tlsServerName: resource.http ? resource.tlsServerName || "" : undefined }, mode: "onChange" }); @@ -210,7 +224,8 @@ export default function GeneralForm() { subdomain: data.http ? data.subdomain : undefined, proxyPort: data.proxyPort, isBaseDomain: data.http ? data.isBaseDomain : undefined, - domainId: data.http ? data.domainId : undefined + domainId: data.http ? data.domainId : undefined, + tlsServerName: data.http ? data.tlsServerName : undefined } ) .catch((e) => { @@ -237,7 +252,8 @@ export default function GeneralForm() { subdomain: data.subdomain, proxyPort: data.proxyPort, isBaseDomain: data.isBaseDomain, - fullDomain: resource.fullDomain + fullDomain: resource.fullDomain, + tlsServerName: data.tlsServerName }); router.refresh(); @@ -545,7 +561,27 @@ export default function GeneralForm() { )} /> )} + {/* New TLS Server Name Field */} +
+ + TLS Server Name + + ( + + + + + + + )} + /> +
)} From 517bc7f6325436ef443757dcc87de7b967e5bd31 Mon Sep 17 00:00:00 2001 From: Matthias Palmetshofer Date: Thu, 10 Apr 2025 00:36:34 +0200 Subject: [PATCH 2/4] added table change to new migration script --- server/setup/migrations.ts | 4 +++- server/setup/scripts/1.3.0.ts | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 server/setup/scripts/1.3.0.ts diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 77248f62..dbeaeea2 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -19,6 +19,7 @@ import m15 from "./scripts/1.0.0-beta15"; import m16 from "./scripts/1.0.0"; import m17 from "./scripts/1.1.0"; import m18 from "./scripts/1.2.0"; +import m19 from "./scripts/1.3.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -37,7 +38,8 @@ const migrations = [ { version: "1.0.0-beta.15", run: m15 }, { version: "1.0.0", run: m16 }, { version: "1.1.0", run: m17 }, - { version: "1.2.0", run: m18 } + { version: "1.2.0", run: m18 }, + { version: "1.3.0", run: m19 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.3.0.ts b/server/setup/scripts/1.3.0.ts new file mode 100644 index 00000000..d9a8e959 --- /dev/null +++ b/server/setup/scripts/1.3.0.ts @@ -0,0 +1,23 @@ +import db from "@server/db"; +import { sql } from "drizzle-orm"; + +const version = "1.1.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + db.transaction((trx) => { + trx.run( + sql`ALTER TABLE 'resources' ADD 'tlsServerName' integer DEFAULT '' NOT NULL;` + ); + }); + + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + console.log(`${version} migration complete`); +} From 64a2cc23c6629616ae968f07fdbbf05d8e04b926 Mon Sep 17 00:00:00 2001 From: Matthias Palmetshofer Date: Fri, 11 Apr 2025 09:52:34 +0200 Subject: [PATCH 3/4] adjusting field description; fix migration script; trying to resolve conflict in updateResource.ts --- server/routers/resource/updateResource.ts | 3 ++- server/setup/scripts/1.3.0.ts | 4 ++-- .../[orgId]/settings/resources/[resourceId]/general/page.tsx | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 54802ccc..23dea616 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -16,7 +16,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import config from "@server/lib/config"; -import { subdomainSchema, tlsNameSchema } from "@server/lib/schemas"; +import { tlsNameSchema } from "@server/lib/schemas"; +import { subdomainSchema } from "@server/lib/schemas"; const updateResourceParamsSchema = z .object({ diff --git a/server/setup/scripts/1.3.0.ts b/server/setup/scripts/1.3.0.ts index d9a8e959..f0b481fc 100644 --- a/server/setup/scripts/1.3.0.ts +++ b/server/setup/scripts/1.3.0.ts @@ -1,7 +1,7 @@ import db from "@server/db"; import { sql } from "drizzle-orm"; -const version = "1.1.0"; +const version = "1.3.0"; export default async function migration() { console.log(`Running setup script ${version}...`); @@ -9,7 +9,7 @@ export default async function migration() { try { db.transaction((trx) => { trx.run( - sql`ALTER TABLE 'resources' ADD 'tlsServerName' integer DEFAULT '' NOT NULL;` + sql`ALTER TABLE 'resources' ADD 'tlsServerName' text DEFAULT '' NOT NULL;` ); }); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index a3fccf26..529b5b97 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -565,7 +565,7 @@ export default function GeneralForm() {
- TLS Server Name + TLS Server Name (optional) Date: Tue, 15 Apr 2025 13:17:46 +0200 Subject: [PATCH 4/4] added advanced section to general page & custom host header field --- server/db/schemas/schema.ts | 3 +- server/routers/resource/listResources.ts | 6 +- server/routers/resource/updateResource.ts | 12 +- server/routers/traefik/getTraefikConfig.ts | 26 ++- server/setup/scripts/1.3.0.ts | 3 + .../resources/[resourceId]/general/page.tsx | 187 ++++++++++++++---- 6 files changed, 199 insertions(+), 38 deletions(-) diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index 2fe5ac2b..5263161b 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -78,7 +78,8 @@ export const resources = sqliteTable("resources", { .notNull() .default(false), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), - tlsServerName: text("tlsServerName").notNull().default("") + tlsServerName: text("tlsServerName").notNull().default(""), + setHostHeader: text("setHostHeader").notNull().default("") }); export const targets = sqliteTable("targets", { diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 56df9128..72788bf2 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -69,7 +69,8 @@ function queryResources( protocol: resources.protocol, proxyPort: resources.proxyPort, enabled: resources.enabled, - tlsServerName: resources.tlsServerName + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) @@ -104,7 +105,8 @@ function queryResources( protocol: resources.protocol, proxyPort: resources.proxyPort, enabled: resources.enabled, - tlsServerName: resources.tlsServerName + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 23dea616..7ceb5657 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -42,7 +42,8 @@ const updateHttpResourceBodySchema = z applyRules: z.boolean().optional(), domainId: z.string().optional(), enabled: z.boolean().optional(), - tlsServerName: z.string().optional() + tlsServerName: z.string().optional(), + setHostHeader: z.string().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -78,6 +79,15 @@ const updateHttpResourceBodySchema = z return true; }, { message: "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name." } + ) + .refine( + (data) => { + if (data.setHostHeader) { + return tlsNameSchema.safeParse(data.setHostHeader).success; + } + return true; + }, + { message: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." } ); export type UpdateResourceResponse = Resource; diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 42a47940..8a952546 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -41,7 +41,8 @@ export async function traefikConfigProvider( orgId: orgs.orgId }, enabled: resources.enabled, - tlsServerName: resources.tlsServerName + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader }) .from(resources) .innerJoin(sites, eq(sites.siteId, resources.siteId)) @@ -141,6 +142,7 @@ export async function traefikConfigProvider( const serviceName = `${resource.resourceId}-service`; const fullDomain = `${resource.fullDomain}`; const transportName = `${resource.resourceId}-transport`; + const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`; if (!resource.enabled) { continue; @@ -295,6 +297,28 @@ export async function traefikConfigProvider( config_output.http.services![serviceName].loadBalancer.serversTransport = transportName; } + // Add the host header middleware + if (resource.setHostHeader) { + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + config_output.http.middlewares[hostHeaderMiddlewareName] = + { + headers: { + customRequestHeaders: { + Host: resource.setHostHeader + } + } + }; + if (!config_output.http.routers![routerName].middlewares) { + config_output.http.routers![routerName].middlewares = []; + } + config_output.http.routers![routerName].middlewares = [ + ...config_output.http.routers![routerName].middlewares, + hostHeaderMiddlewareName + ]; + } + } else { // Non-HTTP (TCP/UDP) configuration const protocol = resource.protocol.toLowerCase(); diff --git a/server/setup/scripts/1.3.0.ts b/server/setup/scripts/1.3.0.ts index f0b481fc..692dacb4 100644 --- a/server/setup/scripts/1.3.0.ts +++ b/server/setup/scripts/1.3.0.ts @@ -11,6 +11,9 @@ export default async function migration() { trx.run( sql`ALTER TABLE 'resources' ADD 'tlsServerName' text DEFAULT '' NOT NULL;` ); + trx.run( + sql`ALTER TABLE 'resources' ADD 'setHostHeader' text DEFAULT '' NOT NULL;` + ); }); console.log(`Migrated database schema`); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 529b5b97..05d263e6 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -73,8 +73,7 @@ const GeneralFormSchema = z proxyPort: z.number().optional(), http: z.boolean(), isBaseDomain: z.boolean().optional(), - domainId: z.string().optional(), - tlsServerName: z.string().optional() + domainId: z.string().optional() }) .refine( (data) => { @@ -104,7 +103,18 @@ const GeneralFormSchema = z message: "Invalid subdomain", path: ["subdomain"] } - ) + ); + +const TransferFormSchema = z.object({ + siteId: z.number() +}); + +const AdvancedFormSchema = z + .object({ + http: z.boolean(), + tlsServerName: z.string().optional(), + setHostHeader: z.string().optional() + }) .refine( (data) => { if (data.tlsServerName) { @@ -116,14 +126,23 @@ const GeneralFormSchema = z message: "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.", path: ["tlsServerName"] } + ) + .refine( + (data) => { + if (data.setHostHeader) { + return tlsNameSchema.safeParse(data.setHostHeader).success; + } + return true; + }, + { + message: "Invalid custom Host Header value. Use domain name format, or save empty to unset the custom Host Header", + path: ["tlsServerName"] + } ); -const TransferFormSchema = z.object({ - siteId: z.number() -}); - type GeneralFormValues = z.infer; type TransferFormValues = z.infer; +type AdvancedFormValues = z.infer; export default function GeneralForm() { const [formKey, setFormKey] = useState(0); @@ -159,8 +178,17 @@ export default function GeneralForm() { proxyPort: resource.proxyPort ? resource.proxyPort : undefined, http: resource.http, isBaseDomain: resource.isBaseDomain ? true : false, - domainId: resource.domainId || undefined, - tlsServerName: resource.http ? resource.tlsServerName || "" : undefined + domainId: resource.domainId || undefined + }, + mode: "onChange" + }); + + const advancedForm = useForm({ + resolver: zodResolver(AdvancedFormSchema), + defaultValues: { + http: resource.http, + tlsServerName: resource.http ? resource.tlsServerName || "" : undefined, + setHostHeader: resource.http ? resource.setHostHeader || "" : undefined }, mode: "onChange" }); @@ -224,8 +252,7 @@ export default function GeneralForm() { subdomain: data.http ? data.subdomain : undefined, proxyPort: data.proxyPort, isBaseDomain: data.http ? data.isBaseDomain : undefined, - domainId: data.http ? data.domainId : undefined, - tlsServerName: data.http ? data.tlsServerName : undefined + domainId: data.http ? data.domainId : undefined } ) .catch((e) => { @@ -252,8 +279,7 @@ export default function GeneralForm() { subdomain: data.subdomain, proxyPort: data.proxyPort, isBaseDomain: data.isBaseDomain, - fullDomain: resource.fullDomain, - tlsServerName: data.tlsServerName + fullDomain: resource.fullDomain }); router.refresh(); @@ -295,6 +321,46 @@ export default function GeneralForm() { setTransferLoading(false); } + async function onSubmitAdvanced(data: AdvancedFormValues) { + setSaveLoading(true); + + const res = await api + .post>( + `resource/${resource?.resourceId}`, + { + tlsServerName: data.http ? data.tlsServerName : undefined, + setHostHeader: data.http ? data.setHostHeader : undefined + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to update resource", + description: formatAxiosError( + e, + "An error occurred while updating the resource" + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: "Resource updated", + description: "The resource has been updated successfully" + }); + + const resource = res.data.data; + + updateResource({ + tlsServerName: data.tlsServerName, + setHostHeader: data.setHostHeader + }); + + router.refresh(); + } + setSaveLoading(false); + } + async function toggleResourceEnabled(val: boolean) { const res = await api .post>( @@ -561,27 +627,7 @@ export default function GeneralForm() { )} /> )} - {/* New TLS Server Name Field */}
-
- - TLS Server Name (optional) - - ( - - - - - - - )} - /> -
)} @@ -637,6 +683,81 @@ export default function GeneralForm() { + {resource.http && ( + <> + + + Advanced + + Adjust advanced settings for the resource, like customize the Host Header or set a TLS Server Name for SNI based routing. + + + + +
+ + {/* New TLS Server Name Field */} +
+ + TLS Server Name (optional) + + ( + + + + + + + )} + /> +
+ {/* New Custom Host Header Field */} +
+ + Custom Host Header (optional) + + ( + + + + + + + )} + /> +
+
+ +
+
+ + + + +
+ + )}