diff --git a/messages/en-US.json b/messages/en-US.json index 7e2d0e5b2..a9e038829 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -4314,9 +4314,12 @@ "resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.", "resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.", "resourceLauncherPrivateClientRequiredTitle": "Client Connection Required", + "resourceLauncherExitNodeTitle": "Exit Node Routing", + "resourceLauncherExitNodeDescription": "After connecting your client, pick this exit node in the client menu to route all traffic through the attached sites.", "resourceLauncherDownloadClient": "Download client", "resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.", "resourceLauncherNoPortRestrictions": "No port restrictions", + "resourceLauncherAllInternetTraffic": "All internet traffic", "resourceLauncherTcp": "TCP", "resourceLauncherUdp": "UDP", "resourceLauncherUnlabeled": "Unlabeled", diff --git a/server/routers/launcher/formatLauncherAccess.ts b/server/routers/launcher/formatLauncherAccess.ts index ca2104ccd..59862b7dc 100644 --- a/server/routers/launcher/formatLauncherAccess.ts +++ b/server/routers/launcher/formatLauncherAccess.ts @@ -125,6 +125,16 @@ export function formatPublicResourceAccess( export function formatSiteResourceAccess( resource: SiteResourceAccessInput ): LauncherAccessFields { + // Exit node (gateway) destinations are always 0.0.0.0/0 — not useful to + // show or copy. The launcher UI renders a localized capability label. + if (resource.mode === "gateway") { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + if ( (resource.mode === "http" || resource.mode === "inference") && resource.fullDomain diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 7eb54c829..e60363c05 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -55,12 +55,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({ }), query: z.string().optional(), mode: z - .enum(["host", "cidr", "http", "ssh", "inference"]) + .enum(["host", "cidr", "http", "ssh", "inference", "gateway"]) .optional() .catch(undefined) .openapi({ type: "string", - enum: ["host", "cidr", "http", "ssh", "inference"], + enum: ["host", "cidr", "http", "ssh", "inference", "gateway"], description: "Filter site resources by mode" }), sort_by: z diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 8c6281dd2..902faf434 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -357,6 +357,12 @@ export default function PrivateResourcesTable({ label: t( "editInternalResourceDialogModeInference" ) + }, + { + value: "gateway", + label: t( + "editInternalResourceDialogModeGateway" + ) } ]} selectedValue={searchParams.get("mode") ?? undefined} diff --git a/src/components/resource-launcher/LauncherResourceAccess.tsx b/src/components/resource-launcher/LauncherResourceAccess.tsx index 81a867e17..71106c125 100644 --- a/src/components/resource-launcher/LauncherResourceAccess.tsx +++ b/src/components/resource-launcher/LauncherResourceAccess.tsx @@ -1,6 +1,7 @@ "use client"; import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; +import { useTranslations } from "next-intl"; import Link from "next/link"; import { LauncherCopyIcon } from "./LauncherCopyIcon"; @@ -8,6 +9,7 @@ type LauncherResourceAccessProps = { accessDisplay: string; accessCopyValue: string; accessUrl?: string | null; + mode?: string; variant: "grid" | "list"; }; @@ -15,15 +17,23 @@ export function LauncherResourceAccess({ accessDisplay, accessCopyValue, accessUrl, + mode, variant }: LauncherResourceAccessProps) { - if (!accessDisplay) { + const t = useTranslations(); + const isExitNode = mode === "gateway"; + const display = isExitNode + ? t("resourceLauncherAllInternetTraffic") + : accessDisplay; + + if (!display) { return null; } const href = accessUrl ?? undefined; - const canLink = href && isSafeUrlForLink(href); + const canLink = !isExitNode && href && isSafeUrlForLink(href); const copyValue = canLink ? href : accessCopyValue; + const showCopy = !isExitNode && Boolean(copyValue); if (variant === "list") { return ( @@ -35,14 +45,14 @@ export function LauncherResourceAccess({ rel="noopener noreferrer" className="min-w-0 truncate text-sm text-muted-foreground hover:underline max-md:overflow-visible max-md:whitespace-nowrap" > - {accessDisplay} + {display} ) : ( - {accessDisplay} + {display} )} - + {showCopy ? : null} ); } @@ -56,14 +66,14 @@ export function LauncherResourceAccess({ rel="noopener noreferrer" className="min-w-0 flex-1 truncate text-sm text-muted-foreground hover:underline" > - {accessDisplay} + {display} ) : ( - {accessDisplay} + {display} )} - + {showCopy ? : null} ); } diff --git a/src/components/resource-launcher/LauncherResourceCard.tsx b/src/components/resource-launcher/LauncherResourceCard.tsx index ac891e84b..7d07b4e3b 100644 --- a/src/components/resource-launcher/LauncherResourceCard.tsx +++ b/src/components/resource-launcher/LauncherResourceCard.tsx @@ -56,6 +56,7 @@ export function LauncherResourceCard({ accessDisplay={resource.accessDisplay} accessCopyValue={resource.accessCopyValue} accessUrl={resource.accessUrl} + mode={resource.mode} variant="grid" /> diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx index 68944153f..9290a073e 100644 --- a/src/components/resource-launcher/LauncherResourcePanel.tsx +++ b/src/components/resource-launcher/LauncherResourcePanel.tsx @@ -654,6 +654,7 @@ function PrivateResourceDetails({ const t = useTranslations(); const isInference = resource.mode === "inference"; const isSsh = resource.mode === "ssh"; + const isExitNode = resource.mode === "gateway"; return (
@@ -678,6 +679,18 @@ function PrivateResourceDetails({ + {isExitNode ? ( + + + + {t("resourceLauncherExitNodeTitle")} + + + {t("resourceLauncherExitNodeDescription")} + + + ) : null} + diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx index eca882c56..2dbf04258 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -51,6 +51,7 @@ export function LauncherResourceRow({ accessDisplay={resource.accessDisplay} accessCopyValue={resource.accessCopyValue} accessUrl={resource.accessUrl} + mode={resource.mode} variant="list" /> diff --git a/src/lib/launcherResourceAccess.ts b/src/lib/launcherResourceAccess.ts index f779bfec6..79798c3b8 100644 --- a/src/lib/launcherResourceAccess.ts +++ b/src/lib/launcherResourceAccess.ts @@ -66,6 +66,16 @@ export function formatPublicResourceAccess( export function formatSiteResourceAccess( resource: SiteResourceAccessInput ): LauncherAccessFields { + // Exit node (gateway) destinations are always 0.0.0.0/0 — not useful to + // show or copy. Callers should render a localized capability label. + if (resource.mode === "gateway") { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + if ( (resource.mode === "http" || resource.mode === "inference") && resource.fullDomain