diff --git a/messages/en-US.json b/messages/en-US.json index bfb07ef74..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", @@ -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 145b35756..a4210ab8e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -492,8 +492,8 @@ export const siteResources = pgTable( name: varchar("name").notNull(), ssl: boolean("ssl").notNull().default(false), mode: varchar("mode") - .$type<"host" | "cidr" | "http" | "ssh" | "inference">() - .notNull(), // "host" | "cidr" | "http" + .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() + .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 a72e15556..a2c2339aa 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -513,8 +513,8 @@ 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">() - .notNull(), // "host" | "cidr" | "http" + .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() + .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/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( 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/lib/ip.ts b/server/lib/ip.ts index 518bb36db..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()]); @@ -808,7 +809,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!, 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/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/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) }); } 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 + }); +} 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 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/(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/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..bec193bae 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({ @@ -107,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 && @@ -128,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) + @@ -143,17 +149,19 @@ export function PrivateResourceInfoSections({ - - {t("access")} - - - - + {showAccess ? ( + + {t("access")} + + + + + ) : null} {showDestination ? ( diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 02b4500ac..8c6281dd2 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]}; } @@ -392,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 ( 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} >