From a17b870ed29f69e1ea72c819735f849393cae725 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 09:35:00 -0400 Subject: [PATCH] Adding gateway resources --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- server/lib/deleteSiteAssociatedResources.ts | 1 - .../siteResource/createSiteResource.ts | 58 +++++++--- .../siteResource/updateSiteResource.ts | 67 +++++++---- .../private/[niceId]/gateway/page.tsx | 106 ++++++++++++++++++ .../resources/private/[niceId]/layout.tsx | 3 +- .../resources/private/create/page.tsx | 37 ++++++ src/components/PrivateResourceInfoBox.tsx | 3 +- src/components/PrivateResourcesTable.tsx | 3 +- src/components/SiteResourcesOverview.tsx | 3 +- src/lib/privateResourceForm.ts | 7 ++ 12 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/gateway/page.tsx diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 145b35756..63ea3b23a 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -492,7 +492,7 @@ export const siteResources = pgTable( name: varchar("name").notNull(), ssl: boolean("ssl").notNull().default(false), mode: varchar("mode") - .$type<"host" | "cidr" | "http" | "ssh" | "inference">() + .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() .notNull(), // "host" | "cidr" | "http" scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index a72e15556..cdd06ea20 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -513,7 +513,7 @@ export const siteResources = sqliteTable("siteResources", { name: text("name").notNull(), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), mode: text("mode") - .$type<"host" | "cidr" | "http" | "ssh" | "inference">() + .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() .notNull(), // "host" | "cidr" | "http" scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts index c0e520846..1ee2d0cf3 100644 --- a/server/lib/deleteSiteAssociatedResources.ts +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -17,7 +17,6 @@ import { performDeleteSiteResources, runSiteResourceDeleteSideEffects } from "@server/lib/deleteSiteResource"; -import logger from "@server/logger"; export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 4c3593f4c..ad978d530 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -53,7 +53,7 @@ const createSiteResourceSchema = z name: z.string().min(1).max(255), niceId: z.string().optional(), // protocol: z.enum(["tcp", "udp"]).optional(), - mode: z.enum(["host", "cidr", "http", "ssh", "inference"]), + mode: z.enum(["host", "cidr", "http", "ssh", "inference", "gateway"]), ssl: z.boolean().optional(), // only used for http mode scheme: z.enum(["http", "https"]).optional(), siteIds: z.array(z.int()).optional(), @@ -165,10 +165,11 @@ const createSiteResourceSchema = z ) .refine( (data) => { - // destination is only optional for ssh mode with native authDaemonMode or inference + // destination is only optional for ssh mode with native authDaemonMode, inference, or gateway if ( (data.mode === "ssh" && data.authDaemonMode === "native") || - data.mode == "inference" + data.mode == "inference" || + data.mode == "gateway" ) { return true; } @@ -179,7 +180,7 @@ const createSiteResourceSchema = z }, { message: - "Destination is required unless mode is ssh with authDaemonMode native or inference" + "Destination is required unless mode is ssh with authDaemonMode native, inference, or gateway" } ) .refine( @@ -447,14 +448,18 @@ export async function createSiteResource( ); } + // gateway resources always route the whole subnet with everything open + const effectiveDestination = + mode === "gateway" ? "0.0.0.0/0" : destination; + // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) - .safeParse(destination).success; + .safeParse(effectiveDestination).success; if ( isIp && - (isIpInCidr(destination!, org.subnet) || - isIpInCidr(destination!, org.utilitySubnet)) + (isIpInCidr(effectiveDestination!, org.subnet) || + isIpInCidr(effectiveDestination!, org.utilitySubnet)) ) { return next( createHttpError( @@ -584,6 +589,32 @@ export async function createSiteResource( tcpPortRangeStringAdjusted = destinationPort ? destinationPort.toString() : "22"; + } else if (mode === "gateway") { + tcpPortRangeStringAdjusted = "*"; + } + + let udpPortRangeStringAdjusted = udpPortRangeString; + if (mode === "gateway") { + udpPortRangeStringAdjusted = "*"; + } else if ( + mode === "http" || + mode === "ssh" || + mode === "inference" + ) { + udpPortRangeStringAdjusted = ""; + } + + // default to true for http/ssh/inference, false otherwise; + // gateway always allows icmp + let disableIcmpAdjusted = disableIcmp ?? false; + if (mode === "gateway") { + disableIcmpAdjusted = false; + } else if ( + mode === "http" || + mode === "ssh" || + mode === "inference" + ) { + disableIcmpAdjusted = true; } // Create the site resource @@ -594,21 +625,14 @@ export async function createSiteResource( mode, ssl, networkId: network ? network.networkId : null, - destination: destination, // the ssh can be null + destination: effectiveDestination, // the ssh can be null scheme, destinationPort, alias: alias ? alias.trim() : null, aliasAddress, tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" || mode == "inference" - ? "" - : udpPortRangeString, - disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" || mode == "inference" - ? true - : false), // default to true for http resources, otherwise false + udpPortRangeString: udpPortRangeStringAdjusted, + disableIcmp: disableIcmpAdjusted, domainId, subdomain: finalSubdomain, fullDomain, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index d5662eb16..aaae8a1e1 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -51,7 +51,9 @@ const updateSiteResourceSchema = z ) .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), - mode: z.enum(["host", "cidr", "http", "ssh", "inference"]).optional(), + mode: z + .enum(["host", "cidr", "http", "ssh", "inference", "gateway"]) + .optional(), ssl: z.boolean().optional(), scheme: z.enum(["http", "https"]).nullish(), destinationPort: z.int().positive().nullish(), @@ -158,10 +160,11 @@ const updateSiteResourceSchema = z if (data.mode === undefined && data.destination === undefined) { return true; } - // destination is only optional for ssh mode with native authDaemonMode or inference + // destination is only optional for ssh mode with native authDaemonMode, inference, or gateway if ( (data.mode === "ssh" && data.authDaemonMode === "native") || - data.mode == "inference" + data.mode == "inference" || + data.mode == "gateway" ) { return true; } @@ -172,7 +175,7 @@ const updateSiteResourceSchema = z }, { message: - "Destination is required unless mode is ssh with authDaemonMode native or inference" + "Destination is required unless mode is ssh with authDaemonMode native, inference, or gateway" } ) .refine( @@ -409,14 +412,18 @@ export async function updateSiteResource( } } + // gateway resources always route the whole subnet with everything open + const effectiveDestination = + mode === "gateway" ? "0.0.0.0/0" : destination; + // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) - .safeParse(destination).success; + .safeParse(effectiveDestination).success; if ( isIp && - (isIpInCidr(destination!, org.subnet) || - isIpInCidr(destination!, org.utilitySubnet)) + (isIpInCidr(effectiveDestination!, org.subnet) || + isIpInCidr(effectiveDestination!, org.utilitySubnet)) ) { return next( createHttpError( @@ -542,6 +549,34 @@ export async function updateSiteResource( tcpPortRangeStringAdjusted = destinationPort ? destinationPort.toString() : "22"; + } else if (mode === "gateway") { + tcpPortRangeStringAdjusted = "*"; + } + + // undefined means "leave unchanged" (partial update); only + // adjusted when the mode is explicitly being changed + let udpPortRangeStringAdjusted = udpPortRangeString; + if (mode === "gateway") { + udpPortRangeStringAdjusted = "*"; + } else if ( + mode === "http" || + mode === "ssh" || + mode === "inference" + ) { + udpPortRangeStringAdjusted = ""; + } + + let disableIcmpAdjusted = disableIcmp; + if (mode === "gateway") { + disableIcmpAdjusted = false; + } else if ( + mode === "http" || + mode === "ssh" || + mode === "inference" + ) { + disableIcmpAdjusted = true; + } else if (mode !== undefined) { + disableIcmpAdjusted = disableIcmp ?? false; } [updatedSiteResource] = await trx @@ -552,7 +587,8 @@ export async function updateSiteResource( mode: mode, scheme, ssl, - destination: destination, + destination: + mode === "gateway" ? effectiveDestination : destination, destinationPort: destinationPort, enabled: enabled, alias: @@ -562,19 +598,8 @@ export async function updateSiteResource( : null : undefined, tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" || mode == "inference" - ? "" - : udpPortRangeString, - disableIcmp: - mode !== undefined - ? disableIcmp || - (mode == "http" || - mode == "ssh" || - mode == "inference" - ? true - : false) - : disableIcmp, + udpPortRangeString: udpPortRangeStringAdjusted, + disableIcmp: disableIcmpAdjusted, domainId, subdomain: finalSubdomain, fullDomain, diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/gateway/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/gateway/page.tsx new file mode 100644 index 000000000..1743ba3f8 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/gateway/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; +import { createGatewayFormSchema } from "@app/lib/privateResourceForm"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; + +export default function PrivateResourceGatewayPage() { + const t = useTranslations(); + const { save, siteResource } = useSaveSiteResource(); + const [selectedSites, setSelectedSites] = useState(() => + buildSelectedSitesForResource(siteResource) + ); + + const formSchema = useMemo(() => createGatewayFormSchema(t), [t]); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + siteIds: siteResource.siteIds, + mode: "gateway" + } + }); + + const [, formAction, saveLoading] = useActionState(async () => { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + await save({ + siteIds: data.siteIds, + mode: "gateway" + }); + }, null); + + return ( + + + + + {t("gatewaySettings")} + + + {t( + "editInternalResourceDialogDestinationGatewayDescription" + )} + + + + + +
+ + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx index 735848d01..31fe7cfae 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -53,7 +53,8 @@ export default async function PrivateResourceLayout( | "cidrSettings" | "httpSettings" | "sshSettings" - | "inferenceSettings"; + | "inferenceSettings" + | "gatewaySettings"; const navItems = [ { diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 22db4a8cd..27ac06e3f 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -164,6 +164,11 @@ export default function CreatePrivateResourcePage() { value: "inference" as const, title: t("createInternalResourceDialogModeInference"), description: t("resourceTypeInferenceDescription") + }, + { + value: "gateway" as const, + title: t("createInternalResourceDialogModeGateway"), + description: t("resourceTypeGatewayDescription") } ]; @@ -560,6 +565,38 @@ export default function CreatePrivateResourcePage() { )} + {/* Gateway destination */} + {mode === "gateway" && ( + + + + {t("gatewaySettings")} + + + {t( + "editInternalResourceDialogDestinationGatewayDescription" + )} + + + + + + + + + + + + + )} + {/* HTTP configuration */} {mode === "http" && ( diff --git a/src/components/PrivateResourceInfoBox.tsx b/src/components/PrivateResourceInfoBox.tsx index a7add3e36..48f7fdd06 100644 --- a/src/components/PrivateResourceInfoBox.tsx +++ b/src/components/PrivateResourceInfoBox.tsx @@ -93,7 +93,8 @@ export function PrivateResourceInfoSections({ cidr: t("editInternalResourceDialogModeCidr"), http: t("editInternalResourceDialogModeHttp"), ssh: t("editInternalResourceDialogModeSsh"), - inference: t("editInternalResourceDialogModeInference") + inference: t("editInternalResourceDialogModeInference"), + gateway: t("editInternalResourceDialogModeGateway") }; const destination = formatSiteResourceDestinationDisplay({ diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 02b4500ac..d0f05c7f5 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -376,7 +376,8 @@ export default function PrivateResourcesTable({ cidr: t("editInternalResourceDialogModeCidr"), http: t("editInternalResourceDialogModeHttp"), ssh: t("editInternalResourceDialogModeSsh"), - inference: t("editInternalResourceDialogModeInference") + inference: t("editInternalResourceDialogModeInference"), + gateway: t("editInternalResourceDialogModeGateway") }; return {modeLabels[resourceRow.mode]}; } diff --git a/src/components/SiteResourcesOverview.tsx b/src/components/SiteResourcesOverview.tsx index d5d1b4271..08feb7f56 100644 --- a/src/components/SiteResourcesOverview.tsx +++ b/src/components/SiteResourcesOverview.tsx @@ -71,7 +71,8 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) { cidr: t("editInternalResourceDialogModeCidr"), http: t("editInternalResourceDialogModeHttp"), ssh: t("editInternalResourceDialogModeSsh"), - inference: t("editInternalResourceDialogModeInference") + inference: t("editInternalResourceDialogModeInference"), + gateway: t("editInternalResourceDialogModeGateway") }; const dest = formatSiteResourceDestinationDisplay({ mode: row.mode, diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index 612cce4d8..ad678bb73 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -652,6 +652,13 @@ export function createCidrFormSchema(t: TranslateFn) { .superRefine((data, ctx) => destinationRefine(data, ctx, t)); } +export function createGatewayFormSchema(t: TranslateFn) { + return z.object({ + siteIds: z.array(z.number().int().positive()).min(1), + mode: z.literal("gateway") + }); +} + export function createHttpFormSchema(t: TranslateFn) { return z .object({