diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index a8627553..5263161b 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -77,7 +77,9 @@ 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(""), + setHostHeader: text("setHostHeader").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 a367ca3e..daac698f 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -69,7 +69,9 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled + enabled: resources.enabled, + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) @@ -103,7 +105,9 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled + enabled: resources.enabled, + 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 df4a41e7..dbf96fea 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -16,6 +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 { tlsNameSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; @@ -42,7 +43,9 @@ 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(), + setHostHeader: z.string().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -69,6 +72,24 @@ 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." } + ) + .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 17e385ed..8a952546 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -40,7 +40,9 @@ export async function traefikConfigProvider( org: { orgId: orgs.orgId }, - enabled: resources.enabled + enabled: resources.enabled, + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader }) .from(resources) .innerJoin(sites, eq(sites.siteId, resources.siteId)) @@ -139,6 +141,8 @@ export async function traefikConfigProvider( const routerName = `${resource.resourceId}-router`; 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; @@ -278,6 +282,43 @@ 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; + } + + // 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/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..692dacb4 --- /dev/null +++ b/server/setup/scripts/1.3.0.ts @@ -0,0 +1,26 @@ +import db from "@server/db"; +import { sql } from "drizzle-orm"; + +const version = "1.3.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + db.transaction((trx) => { + 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`); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 5d6cc81e..05d263e6 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"; @@ -109,8 +109,40 @@ 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) { + 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"] + } + ) + .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"] + } + ); + type GeneralFormValues = z.infer; type TransferFormValues = z.infer; +type AdvancedFormValues = z.infer; export default function GeneralForm() { const [formKey, setFormKey] = useState(0); @@ -151,6 +183,16 @@ export default function GeneralForm() { 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" + }); + const transferForm = useForm({ resolver: zodResolver(TransferFormSchema), defaultValues: { @@ -279,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>( @@ -601,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) + + ( + + + + + + + )} + /> +
+
+ +
+
+ + + + +
+ + )}