From a17b870ed29f69e1ea72c819735f849393cae725 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 09:35:00 -0400 Subject: [PATCH 1/9] 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({ From 3378125f8e76cb82f8b4cf95d9914347919c6a59 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 14:41:20 -0400 Subject: [PATCH 2/9] Can create gateway resource --- messages/en-US.json | 6 ++++ server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- src/components/PrivateResourceInfoBox.tsx | 35 ++++++++++++++--------- src/lib/privateResourceForm.ts | 10 ++++++- 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index bfb07ef74..1cfee968e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -307,6 +307,8 @@ "privateResourceTypeCidrDescription": "Expose a CIDR range on the site network to connected clients", "privateResourceTypeHttpDescription": "Access an HTTP or HTTPS service through a domain", "privateResourceTypeSshDescription": "Access an SSH server from connected clients", + "privateResourceTypeGatewayDescription": "Send all internet traffic to exit through the site network.", + "resourceTypeGatewayDescription": "Send all internet traffic to exit through the site network.", "resourceDomainDescription": "The resource will be served at this fully qualified domain name.", "resourceHTTPSSettings": "HTTPS Settings", "resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS", @@ -2927,6 +2929,7 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeGateway": "Exit Node", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeInference": "AI Gateway", @@ -2938,6 +2941,7 @@ "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", "editInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", + "editInternalResourceDialogDestinationGatewayDescription": "The sites to uses as exit nodes for this resource", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "createInternalResourceDialogNoSitesAvailable": "No Sites Available", @@ -2952,6 +2956,7 @@ "privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.", "hostSettings": "Host", "cidrSettings": "CIDR", + "gatewaySettings": "Exit Node", "createInternalResourceDialogResourceProperties": "Resource Properties", "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Site", @@ -2990,6 +2995,7 @@ "createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeSsh": "SSH", "createInternalResourceDialogModeInference": "AI Gateway", + "createInternalResourceDialogModeGateway": "Exit Node", "scheme": "Scheme", "createInternalResourceDialogScheme": "Scheme", "createInternalResourceDialogEnableSsl": "Enable TLS", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 63ea3b23a..a4210ab8e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -493,7 +493,7 @@ export const siteResources = pgTable( ssl: boolean("ssl").notNull().default(false), mode: varchar("mode") .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() - .notNull(), // "host" | "cidr" | "http" + .notNull(), scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index cdd06ea20..a2c2339aa 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -514,7 +514,7 @@ export const siteResources = sqliteTable("siteResources", { ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), mode: text("mode") .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() - .notNull(), // "host" | "cidr" | "http" + .notNull(), scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/src/components/PrivateResourceInfoBox.tsx b/src/components/PrivateResourceInfoBox.tsx index 48f7fdd06..bec193bae 100644 --- a/src/components/PrivateResourceInfoBox.tsx +++ b/src/components/PrivateResourceInfoBox.tsx @@ -108,15 +108,19 @@ export function PrivateResourceInfoSections({ tcpPortRangeString: siteResource.tcpPortRangeString ?? "*", udpPortRangeString: siteResource.udpPortRangeString ?? "*" }); + const showAccess = siteResource.mode !== "gateway"; const showAlias = siteResource.mode !== "cidr" && siteResource.mode !== "http" && - siteResource.mode !== "inference"; + siteResource.mode !== "inference" && + siteResource.mode !== "gateway"; const showDestination = !( siteResource.mode === "ssh" && siteResource.authDaemonMode === "native" - ) && siteResource.mode !== "inference"; + ) && + siteResource.mode !== "inference" && + siteResource.mode !== "gateway"; const showCertificate = !!( (siteResource.mode === "http" || siteResource.mode === "inference") && siteResource.ssl && @@ -129,7 +133,8 @@ export function PrivateResourceInfoSections({ siteResource.mode !== "inference"; const numSections = - 2 + + 1 + + (showAccess ? 1 : 0) + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + (showCertificate ? 1 : 0) + @@ -144,17 +149,19 @@ export function PrivateResourceInfoSections({ - - {t("access")} - - - - + {showAccess ? ( + + {t("access")} + + + + + ) : null} {showDestination ? ( diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index ad678bb73..4b8464540 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -421,7 +421,14 @@ export function createCreateFormSchema(t: TranslateFn) { .min(1, t("createInternalResourceDialogNameRequired")) .max(255, t("createInternalResourceDialogNameMaxLength")), siteIds: z.array(z.number().int().positive()).optional(), - mode: z.enum(["host", "cidr", "http", "ssh", "inference"]), + mode: z.enum([ + "host", + "cidr", + "http", + "ssh", + "inference", + "gateway" + ]), destination: z.string().nullish(), alias: z.string().nullish(), destinationPort: z @@ -468,6 +475,7 @@ export function createCreateFormSchema(t: TranslateFn) { if ( data.mode !== "ssh" && data.mode !== "inference" && + data.mode !== "gateway" && (!trimmedDestination || trimmedDestination.length < 1) ) { ctx.addIssue({ From 118120c9ce92e10a4e769712e4d4848ca5d75b20 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 14:54:00 -0400 Subject: [PATCH 3/9] Create gateway resources in blueprints --- server/lib/blueprints/privateResources.ts | 52 ++++++++++++++++------- server/lib/blueprints/types.ts | 8 ++-- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 085fed836..55904492b 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -259,6 +259,11 @@ export async function updatePrivateResources( } const isInference = resourceData.mode === "inference"; + const isGateway = resourceData.mode === "gateway"; + // gateway resources always route the whole subnet with everything open + const effectiveDestination = isGateway + ? "0.0.0.0/0" + : resourceData.destination; // Update existing resource const [updatedResource] = await trx @@ -268,23 +273,28 @@ export async function updatePrivateResources( mode: resourceData.mode, ssl: resourceSsl, scheme: resourceData.scheme, - destination: resourceData.destination, + destination: effectiveDestination, destinationPort: resourceData["destination-port"], enabled: resourceEnabled, alias: resourceData.alias || null, - disableIcmp: - resourceData["disable-icmp"] || - (resourceData.mode == "http" || isInference - ? true - : false), // default to true for http/inference resources, otherwise false + disableIcmp: isGateway + ? false // gateway always allows icmp + : resourceData["disable-icmp"] || + (resourceData.mode == "http" || isInference + ? true + : false), // default to true for http/inference resources, otherwise false tcpPortRangeString: resourceData.mode == "http" || isInference ? "443,80" - : resourceData["tcp-ports"], + : isGateway + ? "*" + : resourceData["tcp-ports"], udpPortRangeString: resourceData.mode == "http" || isInference ? "" - : resourceData["udp-ports"], + : isGateway + ? "*" + : resourceData["udp-ports"], fullDomain: resourceData["full-domain"] || null, subdomain: domainInfo ? domainInfo.subdomain : null, domainId: domainInfo ? domainInfo.domainId : null, @@ -529,6 +539,11 @@ export async function updatePrivateResources( } const isInference = resourceData.mode === "inference"; + const isGateway = resourceData.mode === "gateway"; + // gateway resources always route the whole subnet with everything open + const effectiveDestination = isGateway + ? "0.0.0.0/0" + : resourceData.destination; let domainInfo: | { subdomain: string | null; domainId: string } @@ -590,24 +605,29 @@ export async function updatePrivateResources( mode: resourceData.mode, ssl: resourceSsl, scheme: resourceData.scheme, - destination: resourceData.destination, + destination: effectiveDestination, destinationPort: resourceData["destination-port"], enabled: resourceEnabled, alias: resourceData.alias || null, aliasAddress: aliasAddress, - disableIcmp: - resourceData["disable-icmp"] || - (resourceData.mode == "http" || isInference - ? true - : false), // default to true for http/inference resources, otherwise false + disableIcmp: isGateway + ? false // gateway always allows icmp + : resourceData["disable-icmp"] || + (resourceData.mode == "http" || isInference + ? true + : false), // default to true for http/inference resources, otherwise false tcpPortRangeString: resourceData.mode == "http" || isInference ? "443,80" - : resourceData["tcp-ports"], + : isGateway + ? "*" + : resourceData["tcp-ports"], udpPortRangeString: resourceData.mode == "http" || isInference ? "" - : resourceData["udp-ports"], + : isGateway + ? "*" + : resourceData["udp-ports"], fullDomain: resourceData["full-domain"] || null, subdomain: domainInfo ? domainInfo.subdomain : null, domainId: domainInfo ? domainInfo.domainId : null, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index ff3996417..a057eb745 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -612,7 +612,7 @@ export function isTargetsOnlyResource(resource: any): boolean { export const PrivateResourceSchema = z .object({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "http", "ssh", "inference"]), + mode: z.enum(["host", "cidr", "http", "ssh", "inference", "gateway"]), site: z.string().optional(), // DEPRECATED IN FAVOR OF sites sites: z.array(z.string()).optional().default([]), // protocol: z.enum(["tcp", "udp"]).optional(), @@ -652,13 +652,15 @@ export const PrivateResourceSchema = z }) .refine( (data) => { - // destination is optional only for ssh+native or inference; required for everything else + // destination is optional only for ssh+native, inference, or gateway + // (gateway always routes the whole subnet, so destination is ignored); required for everything else const isNativeSSH = data.mode === "ssh" && (data["auth-daemon"] === undefined || data["auth-daemon"].mode === "native"); if ( data.mode !== "inference" && + data.mode !== "gateway" && !isNativeSSH && !data.destination ) { @@ -669,7 +671,7 @@ export const PrivateResourceSchema = z { path: ["destination"], message: - "destination is required unless mode is 'ssh' with auth-daemon mode 'native', or mode is 'inference'" + "destination is required unless mode is 'ssh' with auth-daemon mode 'native', 'inference', or 'gateway'" } ) .refine( From 8ba14c2a8aaa9d84a0aba52bf495712524839d2c Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 15:24:17 -0400 Subject: [PATCH 4/9] Dont show the gateway resources in the table --- src/components/PrivateResourcesTable.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index d0f05c7f5..8c6281dd2 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -393,7 +393,11 @@ export default function PrivateResourcesTable({ cell: ({ row }) => { const resourceRow = row.original; const display = formatDestinationDisplay(resourceRow); - if (resourceRow.destination) { + if ( + resourceRow.destination && + resourceRow.mode !== "gateway" + ) { + // don't show the gateway resource destination which is 0.0.0.0/0 to not confuse people return ( Date: Wed, 23 Sep 2026 15:24:33 -0400 Subject: [PATCH 5/9] Send gateway resources downstream like cidr resources to newt --- server/lib/ip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 518bb36db..2ecfd90da 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -808,7 +808,7 @@ export async function generateSubnetProxyTargetV2( resourceId: siteResource.siteResourceId }); } - } else if (siteResource.mode == "cidr") { + } else if (siteResource.mode == "cidr" || siteResource.mode == "gateway") { targets.push({ sourcePrefixes: [], destPrefix: siteResource.destination!, From e68963b852fa66372eab375969d18784cffdc62c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 09:43:47 -0400 Subject: [PATCH 6/9] Add comments about not processing gateway --- server/lib/ip.ts | 1 + server/routers/olm/buildConfiguration.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 2ecfd90da..c3567b3ef 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -500,6 +500,7 @@ export function generateRemoteSubnets( if (!sr.enabled) return false; if (!sr.destination) return false; + // we purposely filter out the gateway resource here because we add it manually on the client if (sr.mode === "cidr") { // check if its a valid CIDR using zod const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]); diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 6db0fa291..bd82e43ad 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -231,7 +231,7 @@ export async function buildSiteConfigurationForOlmClient( publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, - remoteSubnets: generateRemoteSubnets(allSiteResources), + remoteSubnets: generateRemoteSubnets(allSiteResources), // we dont add the gateway resources here aliases: generateAliasConfig(allSiteResources) }); } From e92911e7c85c440efd8c3d8e235f2af483aa0621 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 14:29:29 -0400 Subject: [PATCH 7/9] Use formatEndpoint where appropriate Fixes fosrl/newt#454 --- server/routers/newt/buildConfiguration.ts | 2 +- server/routers/newt/handleNewtRegisterMessage.ts | 3 ++- server/routers/newt/targets.ts | 9 +++------ server/routers/olm/handleOlmRegisterMessage.ts | 3 ++- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index e1782fd40..6dece5dfa 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -96,7 +96,7 @@ export async function buildClientConfigurationForNewtClient( await updatePeer(client.clients.clientId, { siteId: site.siteId, endpoint: site.endpoint!, - relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`, + relayEndpoint: formatEndpoint(exitNode.endpoint, config.getRawConfig().gerbil.clients_start_port), publicKey: site.publicKey!, serverIP: site.address, serverPort: site.listenPort diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 43dee26f5..4d90aa0aa 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -15,6 +15,7 @@ import { fetchContainers } from "./dockerSocket"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; import { NewtErrorCodes, sendNewtError } from "./error"; +import { formatEndpoint } from "@server/lib/ip"; export const handleNewtRegisterMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -224,7 +225,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { message: { type: "newt/wg/connect", data: { - endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, + endpoint: formatEndpoint(exitNode.endpoint, exitNode.listenPort), relayPort: config.getRawConfig().gerbil.clients_start_port, publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 44aa34637..0dfac34c8 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -4,6 +4,7 @@ import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import { formatEndpoint } from "@server/lib/ip"; export async function addTargets( newtId: string, @@ -14,9 +15,7 @@ export async function addTargets( ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { - return `${target.internalPort ? target.internalPort + ":" : ""}${ - target.ip - }:${target.port}`; + return `${target.internalPort ? target.internalPort + ":" : ""}${formatEndpoint(target.ip, target.port)}`; }); if (payloadTargets.length > 0) { @@ -208,9 +207,7 @@ export async function removeTargets( ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { - return `${target.internalPort ? target.internalPort + ":" : ""}${ - target.ip - }:${target.port}`; + return `${target.internalPort ? target.internalPort + ":" : ""}${formatEndpoint(target.ip, target.port)}`; }); if (payloadTargets.length > 0) { diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index b65a78a70..f583729ef 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -30,6 +30,7 @@ import { } from "#dynamic/lib/exitNodes"; import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes"; import { addPeer, deletePeer } from "../gerbil/peers"; +import { formatEndpoint } from "@server/lib/ip"; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; @@ -508,7 +509,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ? { aliases: exitNodeAliases, connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias - endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, + endpoint: formatEndpoint(exitNode.endpoint, exitNode.listenPort), publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], tunnelIP: `${clientSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it From 90848581a105b2997b0e7e2de625cec3ba452605 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 25 Sep 2026 11:51:38 -0400 Subject: [PATCH 8/9] Push gateway updates to clients --- server/lib/rebuildClientAssociations.ts | 40 ++++++++++++ server/routers/olm/gateway.ts | 82 +++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 server/routers/olm/gateway.ts diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 012c391bf..02b84c993 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -23,6 +23,10 @@ import { import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm"; import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers"; +import { + sendGatewayDisable, + sendGatewaySitesUpdate +} from "@server/routers/olm/gateway"; import { initPeerAddHandshakeBatch, deletePeersBatch as olmDeletePeersBatch @@ -536,6 +540,19 @@ async function rebuildClientAssociationsFromSiteResourceImpl( ); } + // A client that loses access to a gateway resource (it was deleted, or the + // client's roles/users/machines no longer include it) can't keep using it + // as its gateway. The olm ignores this unless it selected this resource. + if ( + siteResource.mode === "gateway" && + clientSiteResourcesToRemove.length > 0 + ) { + await sendGatewayDisable( + clientSiteResourcesToRemove, + siteResource.siteResourceId + ); + } + /////////// process the client-site associations /////////// logger.debug( @@ -2056,6 +2073,29 @@ export async function handleMessagingForUpdatedSiteResource( ); } + // The olm only knows which gateway resource it selected and the sites it + // is currently using for it, so tell the clients that have access to this + // one what changed. Clients that lost access are handled by the rebuild. + if (existingSiteResource?.mode === "gateway") { + const clientIds = mergedAllClients.map((c) => c.clientId); + if ( + updatedSiteResource.mode !== "gateway" || + !updatedSiteResource.enabled + ) { + await sendGatewayDisable( + clientIds, + updatedSiteResource.siteResourceId + ); + } else { + await sendGatewaySitesUpdate( + clientIds, + updatedSiteResource.siteResourceId, + addedSiteIds, + removedSiteIds + ); + } + } + logger.debug( `handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}` ); diff --git a/server/routers/olm/gateway.ts b/server/routers/olm/gateway.ts new file mode 100644 index 000000000..d6a421c3e --- /dev/null +++ b/server/routers/olm/gateway.ts @@ -0,0 +1,82 @@ +import { sendToClientsBatch } from "#dynamic/routers/ws"; +import { db, olms } from "@server/db"; +import { canCompress } from "@server/lib/clientVersionChecks"; +import logger from "@server/logger"; +import { inArray } from "drizzle-orm"; + +// The olm only tracks which gateway (exit node) site resource it selected, by +// its numeric siteResourceId, and the site IDs that resource currently +// resolves to. So all the server has to push is what changed for that one +// resource; the olm ignores a message whose siteResourceId isn't the one it +// selected, which stops a site added to some other gateway resource from +// being pulled into the client's gateway set. (Creates aren't pushed: a +// client has to select a gateway resource before it can be using it.) + +async function sendGatewayMessageToClients( + clientIds: number[], + type: string, + data: Record +): Promise { + const uniqueClientIds = Array.from(new Set(clientIds)); + if (uniqueClientIds.length === 0) { + return; + } + + const olmRows = await db + .select({ + olmId: olms.olmId, + version: olms.version + }) + .from(olms) + .where(inArray(olms.clientId, uniqueClientIds)); + + const payloads = olmRows.map((olm) => ({ + clientId: olm.olmId, + message: { type, data }, + options: { + compress: canCompress(olm.version, "olm"), + incrementConfigVersion: true + } + })); + + if (payloads.length === 0) { + return; + } + + await sendToClientsBatch(payloads).catch((error) => { + logger.error(`Error sending ${type} messages to olms:`, error); + }); +} + +// Tells the olms of the given clients that sites were added to / removed from +// the gateway site resource, so those that selected it can adjust the set of +// sites they use as the gateway. +export async function sendGatewaySitesUpdate( + clientIds: number[], + siteResourceId: number, + addedSiteIds: number[], + removedSiteIds: number[] +): Promise { + if (addedSiteIds.length === 0 && removedSiteIds.length === 0) { + return; + } + + await sendGatewayMessageToClients( + clientIds, + "olm/wg/gateway/sites/update", + { siteResourceId, addedSiteIds, removedSiteIds } + ); +} + +// Tells the olms of the given clients that the gateway site resource can no +// longer be used as a gateway (it was deleted, disabled, changed to another +// mode, or the client lost access to it), so those that selected it drop out +// of gateway mode. +export async function sendGatewayDisable( + clientIds: number[], + siteResourceId: number +): Promise { + await sendGatewayMessageToClients(clientIds, "olm/wg/gateway/disable", { + siteResourceId + }); +} From 397fcbf4c47912b3f83cb19b1a80ebe7d6d6b7f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 28 Sep 2026 11:29:53 -0400 Subject: [PATCH 9/9] Show the right tier in the banner --- messages/en-US.json | 2 +- src/app/[orgId]/settings/(private)/billing/page.tsx | 5 ++--- src/components/TrialBillingBanner.tsx | 11 ++++++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 1cfee968e..cfa151c90 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -26,7 +26,7 @@ "trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.", "trialBannerExpired": "Your trial has expired. Upgrade now to restore access.", "billingTrialBannerTitle": "Free Trial Active", - "billingTrialBannerDescription": "You're currently on a free trial on the business tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.", + "billingTrialBannerDescription": "You're currently on a free trial on the {tier} tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.", "billingTrialBannerUpgrade": "Upgrade Now", "billingTrialBadge": "Free Trial", "trialActive": "Free Trial Active", diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 7869374cd..d60f300d5 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -524,6 +524,7 @@ export default function BillingPage() { }; const currentPlanId = getCurrentPlanId(); + const currentPlan = planOptions.find((p) => p.id === currentPlanId); const visiblePlanOptions = planOptions.filter( (plan) => plan.id !== "home" || currentPlanId === "home" @@ -873,10 +874,8 @@ export default function BillingPage() { {/* Trial Banner */} {isTrial && ( { - const currentPlan = planOptions.find( - (p) => p.id === currentPlanId - ); if (currentPlan?.tierType) { handleStartSubscription(currentPlan.tierType); } diff --git a/src/components/TrialBillingBanner.tsx b/src/components/TrialBillingBanner.tsx index 52fcb4873..4610f5520 100644 --- a/src/components/TrialBillingBanner.tsx +++ b/src/components/TrialBillingBanner.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { Button } from "@app/components/ui/button"; import { ClockIcon, ArrowRight } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -8,9 +7,13 @@ import DismissableBanner from "./DismissableBanner"; type TrialBillingBannerProps = { onUpgrade: () => void; + tierName: string; }; -export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => { +export const TrialBillingBanner = ({ + onUpgrade, + tierName +}: TrialBillingBannerProps) => { const t = useTranslations(); return ( @@ -19,7 +22,9 @@ export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => { version={1} title={t("billingTrialBannerTitle")} titleIcon={} - description={t("billingTrialBannerDescription")} + description={t("billingTrialBannerDescription", { + tier: tierName + })} dismissable={false} >