From 726dc01c588729cbddc4f8f346bf7575d8e25a00 Mon Sep 17 00:00:00 2001 From: wander <127889726+heywander@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:01:46 -0400 Subject: [PATCH 1/3] dashboard: support editing multiple port mappings --- e2e/tests/reverse-proxy-services-tcp.spec.ts | 62 ++- ...proxy-services-udp-no-custom-ports.spec.ts | 47 +- e2e/tests/reverse-proxy-services-udp.spec.ts | 31 +- src/interfaces/ReverseProxy.ts | 9 + .../ReverseProxyLayer4Content.tsx | 484 ++++++++++++++---- .../reverse-proxy/ReverseProxyModal.tsx | 373 ++++++++------ 6 files changed, 736 insertions(+), 270 deletions(-) diff --git a/e2e/tests/reverse-proxy-services-tcp.spec.ts b/e2e/tests/reverse-proxy-services-tcp.spec.ts index 5b54edd40..90b4b36a7 100644 --- a/e2e/tests/reverse-proxy-services-tcp.spec.ts +++ b/e2e/tests/reverse-proxy-services-tcp.spec.ts @@ -20,7 +20,9 @@ let tcpResource = ""; let tcpSubdomain = ""; test.describe.serial("Reverse Proxy - Services (TCP) @reverse-proxy", () => { - test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + test("Should create a network with a resource", async ({ + dashboardAsOwner: page, + }) => { await deleteServicesByPrefix(page, "tcp-svc-"); await deleteNetworksByPrefix(page, "rp-tcp-net-"); await navigateTo(page, "/networks"); @@ -54,39 +56,78 @@ test.describe.serial("Reverse Proxy - Services (TCP) @reverse-proxy", () => { } }); - test("Should create a TCP service", async ({ dashboardAsOwner: page }) => { + test("Should create a mixed multi-port service", async ({ + dashboardAsOwner: page, + }) => { await gotoReverseProxyPage(page, "/reverse-proxy/services"); const subdomain = generateRandomName("tcp-svc-"); tcpSubdomain = subdomain; await page.getByTestId("add-service").first().click(); - await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ + timeout: 10_000, + }); await page.getByTestId("proxy-subdomain-input").fill(subdomain); await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); await page.getByTestId("service-mode-select-button").click({ force: true }); await page.getByTestId("service-mode-option-tcp").click({ force: true }); - await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ + timeout: 10_000, + }); await selectL4Resource(page, tcpResource); - await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 }); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ + timeout: 10_000, + }); await page.getByTestId("listen-port-input").fill("3306"); await page.getByTestId("destination-port-input").fill("3306"); + + await page.getByTestId("add-port-mapping").click(); + await page.getByTestId("listen-port-start-1").fill("3307"); + await page.getByTestId("destination-port-start-1").fill("3307"); + await page.getByTestId("destination-port-end-1").fill("3308"); + await expect(page.getByText(/same number of ports/i)).toBeVisible(); + await page.getByTestId("destination-port-end-1").fill("3307"); + + await page.getByTestId("add-port-mapping").click(); + const udpMapping = page.getByTestId("port-mapping-2"); + await udpMapping.getByRole("combobox").click(); + await page.getByRole("option", { name: "UDP", exact: true }).click(); + await page.getByTestId("listen-port-start-2").fill("3308"); + await page.getByTestId("destination-port-start-2").fill("3308"); + await page.getByRole("button", { name: "Move mapping 3 up" }).click(); + await page.getByTestId("proxy-continue").click(); await addAccessControlRules(page); await page.getByTestId("proxy-continue").click(); await page.getByTestId("connection-timeout-input").fill("20s"); + await page.getByTestId("udp-session-timeout-input").fill("30s"); await page.getByTestId("toggle-preserve-client-ip").click(); await page.getByTestId("submit-service").click(); await resetServiceFilters(page); - await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("TCP", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect( + page + .locator("tr") + .filter({ hasText: subdomain }) + .getByText("TCP", { exact: true }), + ).toBeVisible({ timeout: 30_000 }); }); - test("Should edit the TCP service and delete it", async ({ dashboardAsOwner: page }) => { + test("Should edit the TCP service and delete it", async ({ + dashboardAsOwner: page, + }) => { await openServiceEdit(page, tcpSubdomain); + await expect(page.locator('[data-testid^="port-mapping-"]')).toHaveCount(3); + + await page.getByRole("button", { name: "Move mapping 2 up" }).click(); + await page.getByRole("button", { name: "Move mapping 1 down" }).click(); + await page.getByRole("button", { name: "Remove mapping 3" }).click(); + await expect(page.locator('[data-testid^="port-mapping-"]')).toHaveCount(2); + await page.getByTestId("listen-port-input").fill("5432"); await page.getByTestId("destination-port-input").fill("5432"); @@ -101,10 +142,9 @@ test.describe.serial("Reverse Proxy - Services (TCP) @reverse-proxy", () => { await resetServiceFilters(page); const row = page.locator("tr").filter({ hasText: tcpSubdomain }); - await expect(row.locator("[data-access-control-cell]")).toContainText( - "0", - { timeout: 10_000 }, - ); + await expect(row.locator("[data-access-control-cell]")).toContainText("0", { + timeout: 10_000, + }); await deleteService(page, tcpSubdomain); }); diff --git a/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts b/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts index 653cc02ee..852d707ea 100644 --- a/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts +++ b/e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts @@ -19,8 +19,11 @@ let udpNetwork = ""; let udpResource = ""; let udpSubdomain = ""; -test.describe.serial("Reverse Proxy - Services (UDP, no custom ports) @reverse-proxy", () => { - test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { +test.describe + .serial("Reverse Proxy - Services (UDP, no custom ports) @reverse-proxy", () => { + test("Should create a network with a resource", async ({ + dashboardAsOwner: page, + }) => { await deleteServicesByPrefix(page, "udp-np-svc-"); await deleteNetworksByPrefix(page, "rp-udp-np-net-"); await navigateTo(page, "/networks"); @@ -54,43 +57,65 @@ test.describe.serial("Reverse Proxy - Services (UDP, no custom ports) @reverse-p } }); - test("Should create a UDP service on the no-custom-ports cluster", async ({ dashboardAsOwner: page }) => { + test("Should create a UDP service on the no-custom-ports cluster", async ({ + dashboardAsOwner: page, + }) => { await gotoReverseProxyPage(page, "/reverse-proxy/services"); const subdomain = generateRandomName("udp-np-svc-"); udpSubdomain = subdomain; await page.getByTestId("add-service").first().click(); - await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ + timeout: 10_000, + }); await page.getByTestId("proxy-subdomain-input").fill(subdomain); await selectProxyDomain(page, NO_CUSTOM_PORTS_DOMAIN); await page.getByTestId("service-mode-select-button").click({ force: true }); await page.getByTestId("service-mode-option-udp").click({ force: true }); - await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ + timeout: 10_000, + }); await selectL4Resource(page, udpResource); // Listen port is auto-assigned when the cluster has custom ports disabled - await expect(page.getByTestId("listen-port-input")).toBeDisabled({ timeout: 10_000 }); - await expect(page.getByTestId("listen-port-input")).toHaveAttribute("placeholder", "Auto"); + await expect(page.getByTestId("listen-port-input")).toBeDisabled({ + timeout: 10_000, + }); + await expect(page.getByTestId("listen-port-input")).toHaveAttribute( + "placeholder", + "Auto", + ); await page.getByTestId("destination-port-input").fill("5060"); + await page.getByTestId("destination-port-end-0").fill("5061"); + await expect( + page.getByText( + "An auto-assigned listener supports one destination port, not a range.", + ), + ).toBeVisible(); + await page.getByTestId("destination-port-end-0").fill("5060"); await page.getByTestId("proxy-continue").click(); await addAccessControlRules(page); await page.getByTestId("proxy-continue").click(); - await page.getByTestId("connection-timeout-input").fill("30s"); + await page.getByTestId("udp-session-timeout-input").fill("30s"); await page.getByTestId("submit-service").click(); await resetServiceFilters(page); const row = page.locator("tr").filter({ hasText: subdomain }); - await expect(row.getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText("UDP", { exact: true })).toBeVisible({ + timeout: 30_000, + }); await expect(row).toContainText(NO_CUSTOM_PORTS_DOMAIN); }); - test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => { + test("Should edit the UDP service and delete it", async ({ + dashboardAsOwner: page, + }) => { await openServiceEdit(page, udpSubdomain); // Listen port must remain auto-assigned on this cluster @@ -102,7 +127,7 @@ test.describe.serial("Reverse Proxy - Services (UDP, no custom ports) @reverse-p await removeAllAccessControlRules(page); await page.getByTestId("proxy-tab-settings").click({ force: true }); - await page.getByTestId("connection-timeout-input").fill(""); + await page.getByTestId("udp-session-timeout-input").fill(""); await saveServiceEdit(page); diff --git a/e2e/tests/reverse-proxy-services-udp.spec.ts b/e2e/tests/reverse-proxy-services-udp.spec.ts index 7cc39cd46..c780f97d1 100644 --- a/e2e/tests/reverse-proxy-services-udp.spec.ts +++ b/e2e/tests/reverse-proxy-services-udp.spec.ts @@ -20,7 +20,9 @@ let udpResource = ""; let udpSubdomain = ""; test.describe.serial("Reverse Proxy - Services (UDP) @reverse-proxy", () => { - test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => { + test("Should create a network with a resource", async ({ + dashboardAsOwner: page, + }) => { await deleteServicesByPrefix(page, "udp-svc-"); await deleteNetworksByPrefix(page, "rp-udp-net-"); await navigateTo(page, "/networks"); @@ -60,16 +62,22 @@ test.describe.serial("Reverse Proxy - Services (UDP) @reverse-proxy", () => { udpSubdomain = subdomain; await page.getByTestId("add-service").first().click(); - await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ + timeout: 10_000, + }); await page.getByTestId("proxy-subdomain-input").fill(subdomain); await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); await page.getByTestId("service-mode-select-button").click({ force: true }); await page.getByTestId("service-mode-option-udp").click({ force: true }); // Wait for mode switch to take effect - await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ + timeout: 10_000, + }); await selectL4Resource(page, udpResource); - await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 }); + await expect(page.getByTestId("listen-port-input")).toBeEnabled({ + timeout: 10_000, + }); await page.getByTestId("listen-port-input").fill("5060"); await page.getByTestId("destination-port-input").fill("5060"); await page.getByTestId("proxy-continue").click(); @@ -77,14 +85,21 @@ test.describe.serial("Reverse Proxy - Services (UDP) @reverse-proxy", () => { await addAccessControlRules(page); await page.getByTestId("proxy-continue").click(); - await page.getByTestId("connection-timeout-input").fill("30s"); + await page.getByTestId("udp-session-timeout-input").fill("30s"); await page.getByTestId("submit-service").click(); await resetServiceFilters(page); - await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect( + page + .locator("tr") + .filter({ hasText: subdomain }) + .getByText("UDP", { exact: true }), + ).toBeVisible({ timeout: 30_000 }); }); - test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => { + test("Should edit the UDP service and delete it", async ({ + dashboardAsOwner: page, + }) => { await openServiceEdit(page, udpSubdomain); await page.getByTestId("listen-port-input").fill("5061"); @@ -94,7 +109,7 @@ test.describe.serial("Reverse Proxy - Services (UDP) @reverse-proxy", () => { await removeAllAccessControlRules(page); await page.getByTestId("proxy-tab-settings").click({ force: true }); - await page.getByTestId("connection-timeout-input").fill(""); + await page.getByTestId("udp-session-timeout-input").fill(""); await saveServiceEdit(page); diff --git a/src/interfaces/ReverseProxy.ts b/src/interfaces/ReverseProxy.ts index 4196a2326..cbed3c7b2 100644 --- a/src/interfaces/ReverseProxy.ts +++ b/src/interfaces/ReverseProxy.ts @@ -11,6 +11,7 @@ export interface ReverseProxy { domain: string; mode?: ServiceMode; listen_port?: number; + port_mappings?: ReverseProxyPortMapping[]; port_auto_assigned?: boolean; proxy_cluster?: string; targets: ReverseProxyTarget[]; @@ -25,6 +26,14 @@ export interface ReverseProxy { access_groups?: string[]; } +export interface ReverseProxyPortMapping { + protocol: ServiceMode.TCP | ServiceMode.UDP | ServiceMode.TLS; + listen_port_start: number; + listen_port_end: number; + target_port_start: number; + target_port_end: number; +} + export const CrowdSecMode = { OFF: "off", ENFORCE: "enforce", diff --git a/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx b/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx index a60a224c0..ffe49b130 100644 --- a/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx +++ b/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx @@ -1,7 +1,21 @@ +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import { HelpTooltip } from "@components/HelpTooltip"; import { Input } from "@components/Input"; import { Label } from "@components/Label"; -import { ArrowRight } from "lucide-react"; -import React, { useRef } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@components/Select"; +import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react"; +import React from "react"; +import { + ReverseProxyPortMapping, + ServiceMode, +} from "@/interfaces/ReverseProxy"; import { Network, NetworkResource } from "@/interfaces/Network"; import { Peer } from "@/interfaces/Peer"; import ReverseProxyAddressInput, { @@ -10,125 +24,415 @@ import ReverseProxyAddressInput, { import ReverseProxyTargetSelector, { type Target, } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector"; -import { HelpTooltip } from "@components/HelpTooltip"; type Props = { l4Target: Target | undefined; setL4Target: React.Dispatch>; - isListenPortSupported: boolean; - listenPort: number; - setListenPort: (port: number) => void; - port: number; - setPort: (port: number) => void; + supportsCustomPorts: boolean; + portMappings: ReverseProxyPortMapping[]; + setPortMappings: React.Dispatch< + React.SetStateAction + >; + defaultProtocol: ServiceMode.TCP | ServiceMode.UDP | ServiceMode.TLS; initialResource?: NetworkResource; initialPeer?: Peer; initialNetwork?: Network; }; +const mappingProtocols = [ServiceMode.TCP, ServiceMode.UDP, ServiceMode.TLS]; + +export function emptyPortMapping( + protocol: ServiceMode.TCP | ServiceMode.UDP | ServiceMode.TLS, +): ReverseProxyPortMapping { + return { + protocol, + listen_port_start: 0, + listen_port_end: 0, + target_port_start: 0, + target_port_end: 0, + }; +} + +const isValidPort = (port: number) => + Number.isInteger(port) && port >= 1 && port <= 65535; + +const listensOnExplicitPort = ( + mapping: ReverseProxyPortMapping, + supportsCustomPorts: boolean, +) => mapping.protocol === ServiceMode.TLS || supportsCustomPorts; + +export function getPortMappingErrors( + mappings: ReverseProxyPortMapping[], + supportsCustomPorts: boolean, +): string[][] { + if (mappings.length === 0) return [["Add at least one port mapping."]]; + + const errors = mappings.map(() => [] as string[]); + mappings.forEach((mapping, index) => { + const explicitListener = listensOnExplicitPort( + mapping, + supportsCustomPorts, + ); + if (!mappingProtocols.includes(mapping.protocol)) { + errors[index].push("Select TCP, UDP, or TLS."); + } + if (explicitListener) { + if ( + !isValidPort(mapping.listen_port_start) || + !isValidPort(mapping.listen_port_end) + ) { + errors[index].push("Listener ports must be between 1 and 65535."); + } else if (mapping.listen_port_start > mapping.listen_port_end) { + errors[index].push("The listener range is reversed."); + } + } else if (mappings.length > 1) { + errors[index].push( + "This cluster only supports one auto-assigned TCP or UDP listener.", + ); + } + + if ( + !isValidPort(mapping.target_port_start) || + !isValidPort(mapping.target_port_end) + ) { + errors[index].push("Destination ports must be between 1 and 65535."); + } else if (mapping.target_port_start > mapping.target_port_end) { + errors[index].push("The destination range is reversed."); + } + + if ( + !explicitListener && + isValidPort(mapping.target_port_start) && + isValidPort(mapping.target_port_end) && + mapping.target_port_start !== mapping.target_port_end + ) { + errors[index].push( + "An auto-assigned listener supports one destination port, not a range.", + ); + } + + if ( + explicitListener && + isValidPort(mapping.listen_port_start) && + isValidPort(mapping.listen_port_end) && + mapping.listen_port_start <= mapping.listen_port_end && + isValidPort(mapping.target_port_start) && + isValidPort(mapping.target_port_end) && + mapping.target_port_start <= mapping.target_port_end && + mapping.listen_port_end - mapping.listen_port_start !== + mapping.target_port_end - mapping.target_port_start + ) { + errors[index].push( + "Listener and destination ranges must contain the same number of ports.", + ); + } + + for (let otherIndex = 0; otherIndex < index; otherIndex += 1) { + const other = mappings[otherIndex]; + if ( + mapping.protocol === other.protocol && + explicitListener && + listensOnExplicitPort(other, supportsCustomPorts) && + isValidPort(mapping.listen_port_start) && + isValidPort(mapping.listen_port_end) && + isValidPort(other.listen_port_start) && + isValidPort(other.listen_port_end) && + mapping.listen_port_start <= other.listen_port_end && + other.listen_port_start <= mapping.listen_port_end + ) { + errors[index].push( + `Listener range overlaps mapping ${ + otherIndex + 1 + } for ${mapping.protocol.toUpperCase()}.`, + ); + } + } + }); + return errors; +} + export default function ReverseProxyLayer4Content({ l4Target, setL4Target, - isListenPortSupported, - listenPort, - setListenPort, - port, - setPort, + supportsCustomPorts, + portMappings, + setPortMappings, + defaultProtocol, initialResource, initialPeer, initialNetwork, }: Readonly) { - const listenPortRef = useRef(null); - const portRef = useRef(null); + const errors = getPortMappingErrors(portMappings, supportsCustomPorts); + + const updateMapping = ( + index: number, + update: Partial, + ) => { + setPortMappings((current) => + current.map((mapping, mappingIndex) => + mappingIndex === index ? { ...mapping, ...update } : mapping, + ), + ); + }; + + const updateRangeStart = ( + index: number, + field: "listen" | "target", + value: number, + ) => { + const mapping = portMappings[index]; + const startKey = `${field}_port_start` as const; + const endKey = `${field}_port_end` as const; + const followsStart = + mapping[endKey] === 0 || mapping[endKey] === mapping[startKey]; + updateMapping(index, { + [startKey]: value, + ...(followsStart ? { [endKey]: value } : {}), + }); + }; + + const moveMapping = (index: number, offset: -1 | 1) => { + setPortMappings((current) => { + const destination = index + offset; + if (destination < 0 || destination >= current.length) return current; + const next = [...current]; + [next[index], next[destination]] = [next[destination], next[index]]; + return next; + }); + }; return ( -
+
{!initialResource && !initialPeer && ( { - setL4Target(selection); - if (selection) { - setTimeout(() => { - if (isListenPortSupported) { - listenPortRef.current?.focus(); - } else { - portRef.current?.focus(); - } - }, 0); - } - }} + onChange={setL4Target} /> )} -
-
- -
- setListenPort(parseInt(e.target.value) || 0)} - disabled={!isListenPortSupported || !l4Target} - aria-label="Public listen port" - data-testid="listen-port-input" - /> -
+
+ +
+
- -
-
- -
- -
-
+
+ +
+
-
- setPort(parseInt(e.target.value) || 0)} - disabled={!l4Target} - aria-label="Destination port" - className={"rounded-l-none"} - data-testid="destination-port-input" - /> -
+ + A hostname selects the proxy cluster. Raw UDP clients still + connect by address and port; UDP has no hostname routing on the + wire. +
+
+ + {portMappings.map((mapping, index) => { + const listenerSupported = listensOnExplicitPort( + mapping, + supportsCustomPorts, + ); + return ( +
+
+
+ + +
+ +
+ +
+ + updateRangeStart( + index, + "listen", + Number.parseInt(event.target.value, 10) || 0, + ) + } + disabled={!listenerSupported || !l4Target} + aria-label={`Mapping ${index + 1} listener start`} + data-testid={ + index === 0 + ? "listen-port-input" + : `listen-port-start-${index}` + } + /> + – + + updateMapping(index, { + listen_port_end: + Number.parseInt(event.target.value, 10) || 0, + }) + } + disabled={!listenerSupported || !l4Target} + aria-label={`Mapping ${index + 1} listener end`} + data-testid={`listen-port-end-${index}`} + /> +
+
+ +
+ +
+ + updateRangeStart( + index, + "target", + Number.parseInt(event.target.value, 10) || 0, + ) + } + disabled={!l4Target} + aria-label={`Mapping ${index + 1} destination start`} + data-testid={ + index === 0 + ? "destination-port-input" + : `destination-port-start-${index}` + } + /> + – + + updateMapping(index, { + target_port_end: + Number.parseInt(event.target.value, 10) || 0, + }) + } + disabled={!l4Target} + aria-label={`Mapping ${index + 1} destination end`} + data-testid={`destination-port-end-${index}`} + /> +
+
+ +
+ + + +
+
+ + {errors[index]?.length > 0 && ( +
+ {errors[index].map((error) => ( +

{error}

+ ))} +
+ )} +
+ ); + })}
); diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index 6e24df90a..29d952437 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -54,6 +54,7 @@ import { ReverseProxy, ReverseProxyAuth, ReverseProxyDomain, + ReverseProxyPortMapping, ReverseProxyTarget, ReverseProxyTargetProtocol, ReverseProxyTargetType, @@ -68,7 +69,10 @@ import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal"; import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal"; import AuthNetBirdOnlyModal from "@/modules/reverse-proxy/auth/AuthNetBirdOnlyModal"; import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets"; -import ReverseProxyLayer4Content from "@/modules/reverse-proxy/ReverseProxyLayer4Content"; +import ReverseProxyLayer4Content, { + emptyPortMapping, + getPortMappingErrors, +} from "@/modules/reverse-proxy/ReverseProxyLayer4Content"; import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal"; import { type Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector"; import { useReverseProxyAddress } from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput"; @@ -171,12 +175,54 @@ export default function ReverseProxyModal({ return undefined; }); - const [port, setPort] = useState( - reverseProxy?.targets?.[0]?.port || 0, + const [portMappings, setPortMappings] = useState( + () => { + if (reverseProxy?.port_mappings?.length) { + return reverseProxy.port_mappings.map((mapping) => ({ ...mapping })); + } + if (reverseProxy && isL4ServiceMode(reverseProxy.mode)) { + const protocol = + reverseProxy.mode as ReverseProxyPortMapping["protocol"]; + const listenPort = reverseProxy.listen_port ?? 0; + const targetPort = reverseProxy.targets?.[0]?.port ?? 0; + return [ + { + protocol, + listen_port_start: listenPort, + listen_port_end: listenPort, + target_port_start: targetPort, + target_port_end: targetPort, + }, + ]; + } + return []; + }, ); - const [listenPort, setListenPort] = useState( - reverseProxy?.listen_port || 0, + const effectiveL4Mode = + portMappings[0]?.protocol ?? + (serviceMode as ReverseProxyPortMapping["protocol"]); + + // The first mapping is the legacy mode/listener compatibility mirror. Keep + // the selector, title, and protocol-specific settings aligned when mappings + // are edited or reordered. + React.useEffect(() => { + if ( + isL4ServiceMode(serviceMode) && + portMappings[0]?.protocol && + serviceMode !== portMappings[0].protocol + ) { + setServiceMode(portMappings[0].protocol); + } + }, [portMappings, serviceMode]); + + const hasTCPMappings = portMappings.some( + (mapping) => + mapping.protocol === ServiceMode.TCP || + mapping.protocol === ServiceMode.TLS, + ); + const hasUDPMappings = portMappings.some( + (mapping) => mapping.protocol === ServiceMode.UDP, ); // CIDR detection for L4 subnet resources @@ -188,18 +234,27 @@ export default function ReverseProxyModal({ reverseProxy?.targets?.[0]?.options?.proxy_protocol ?? false, ); - const [timeoutOption, setTimeoutOption] = useState( - reverseProxy?.targets?.[0]?.options?.request_timeout ?? - reverseProxy?.targets?.[0]?.options?.session_idle_timeout ?? - "", + const [connectionTimeoutOption, setConnectionTimeoutOption] = useState( + reverseProxy?.targets?.[0]?.options?.request_timeout ?? "", + ); + const [udpSessionTimeoutOption, setUDPSessionTimeoutOption] = useState( + reverseProxy?.targets?.[0]?.options?.session_idle_timeout ?? "", ); - const timeoutError = useMemo(() => { - if (!timeoutOption) return undefined; - return serviceMode === ServiceMode.UDP - ? validateSessionIdleTimeout(timeoutOption) - : validateTimeout(timeoutOption); - }, [timeoutOption, serviceMode]); + const connectionTimeoutError = useMemo( + () => + connectionTimeoutOption + ? validateTimeout(connectionTimeoutOption) + : undefined, + [connectionTimeoutOption], + ); + const udpSessionTimeoutError = useMemo( + () => + udpSessionTimeoutOption + ? validateSessionIdleTimeout(udpSessionTimeoutOption) + : undefined, + [udpSessionTimeoutOption], + ); const [targets, setTargets] = useState( reverseProxy?.targets || [], @@ -248,12 +303,14 @@ export default function ReverseProxyModal({ [domains, baseDomain], ); - // Whether a custom listen port is supported (TLS always, TCP/UDP only when cluster supports it) - const isListenPortSupported = useMemo(() => { - if (serviceMode !== ServiceMode.TCP && serviceMode !== ServiceMode.UDP) - return true; - return selectedDomain?.supports_custom_ports ?? false; - }, [selectedDomain, serviceMode]); + const supportsCustomPorts = selectedDomain?.supports_custom_ports ?? false; + const portMappingErrors = useMemo( + () => getPortMappingErrors(portMappings, supportsCustomPorts), + [portMappings, supportsCustomPorts], + ); + const portMappingsValid = portMappingErrors.every( + (mappingErrors) => mappingErrors.length === 0, + ); const [passHostHeader, setPassHostHeader] = useState( reverseProxy?.pass_host_header ?? false, @@ -284,13 +341,10 @@ export default function ReverseProxyModal({ // Access groups for NetBird-only services. Distinct from bearerGroups // (which gates SSO callers); these groups gate inbound peers on // private services and feed the auto-generated private-access policy. - const [ - accessGroups, - setAccessGroups, - { save: saveAccessGroups }, - ] = useGroupHelper({ - initial: reverseProxy?.access_groups ?? [], - }); + const [accessGroups, setAccessGroups, { save: saveAccessGroups }] = + useGroupHelper({ + initial: reverseProxy?.access_groups ?? [], + }); // Direct upstream is service-level in the UI; on save it patches the // (single) cluster target's options.direct_upstream. Defaults off for @@ -298,9 +352,8 @@ export default function ReverseProxyModal({ // effectiveDirectUpstream below since they have no WireGuard endpoint // to fall back to. const [directUpstream, setDirectUpstream] = useState( - reverseProxy?.targets?.some( - (t) => t.options?.direct_upstream === true, - ) ?? false, + reverseProxy?.targets?.some((t) => t.options?.direct_upstream === true) ?? + false, ); // Cluster targets are reached over the embedded proxy's host stack — @@ -308,10 +361,7 @@ export default function ReverseProxyModal({ // WireGuard tunnel they have no other endpoint for, so we force the // toggle on and lock it whenever any target is a cluster. const hasClusterTarget = useMemo( - () => - targets.some( - (t) => t.target_type === ReverseProxyTargetType.CLUSTER, - ), + () => targets.some((t) => t.target_type === ReverseProxyTargetType.CLUSTER), [targets], ); const effectiveDirectUpstream = hasClusterTarget || directUpstream; @@ -366,21 +416,19 @@ export default function ReverseProxyModal({ // (auth, access control, advanced) — including reaching the Auth tab // to enable bearer auth for a private service. const canContinueToSettings = useMemo(() => { - const subdomainRequired = - selectedDomain?.require_subdomain === true; + const subdomainRequired = selectedDomain?.require_subdomain === true; const isSubdomainValid = baseDomain.length > 0 && !domainAlreadyExists && (subdomain.length > 0 || !subdomainRequired); - const isValidPort = (port: number) => port >= 1 && port <= 65535; const hasHttpEndpoint = !isL4Mode && targets.length > 0; const hasL4Endpoint = !isPrivate && isL4Mode && !!l4Target && l4IsValidCidrHost && - isValidPort(port) && - (!isListenPortSupported || isValidPort(listenPort)); + portMappings.length > 0 && + portMappingsValid; const hasAnyEndpoint = hasHttpEndpoint || hasL4Endpoint; return isSubdomainValid && hasAnyEndpoint; }, [ @@ -393,9 +441,8 @@ export default function ReverseProxyModal({ isL4Mode, l4Target, l4IsValidCidrHost, - port, - isListenPortSupported, - listenPort, + portMappings.length, + portMappingsValid, isPrivate, ]); @@ -486,34 +533,30 @@ export default function ReverseProxyModal({ : [], }; - const l4TargetPayload: ReverseProxyTarget | undefined = l4Target - ? { - target_id: l4Target?.peerId || l4Target?.resourceId || "", - target_type: l4Target?.type, - port: port, - protocol: - serviceMode === ServiceMode.TLS - ? ReverseProxyTargetProtocol.TCP - : serviceMode === ServiceMode.UDP - ? ReverseProxyTargetProtocol.UDP - : ReverseProxyTargetProtocol.TCP, - host: l4IsCidrRange ? l4Target?.host : undefined, - enabled: true, - options: (() => { - const opts: Record = {}; - if (serviceMode !== ServiceMode.UDP && proxyProtocol) - opts.proxy_protocol = true; - if (timeoutOption) { - opts[ - serviceMode === ServiceMode.UDP - ? "session_idle_timeout" - : "request_timeout" - ] = timeoutOption; - } - return Object.keys(opts).length ? opts : undefined; - })(), - } - : undefined; + const primaryMapping = portMappings[0]; + const l4TargetPayload: ReverseProxyTarget | undefined = + l4Target && primaryMapping + ? { + target_id: l4Target?.peerId || l4Target?.resourceId || "", + target_type: l4Target?.type, + port: primaryMapping.target_port_start, + protocol: + primaryMapping.protocol === ServiceMode.UDP + ? ReverseProxyTargetProtocol.UDP + : ReverseProxyTargetProtocol.TCP, + host: l4IsCidrRange ? l4Target?.host : undefined, + enabled: true, + options: (() => { + const opts: Record = {}; + if (hasTCPMappings && proxyProtocol) opts.proxy_protocol = true; + if (hasTCPMappings && connectionTimeoutOption) + opts.request_timeout = connectionTimeoutOption; + if (hasUDPMappings && udpSessionTimeoutOption) + opts.session_idle_timeout = udpSessionTimeoutOption; + return Object.keys(opts).length ? opts : undefined; + })(), + } + : undefined; const rawSubmittedTargets = isL4Mode && l4TargetPayload ? [l4TargetPayload] : targets; @@ -533,13 +576,21 @@ export default function ReverseProxyModal({ })) : rawSubmittedTargets; + const canSendPortMappings = portMappings.every( + (mapping) => mapping.protocol === ServiceMode.TLS || supportsCustomPorts, + ); + handleCreateOrUpdateProxy({ data: { name: fullDomain, domain: fullDomain, - mode: isL4Mode ? (serviceMode as ServiceMode) : undefined, + mode: isL4Mode ? effectiveL4Mode : undefined, listen_port: - isL4Mode && isListenPortSupported ? listenPort : undefined, + isL4Mode && canSendPortMappings + ? primaryMapping?.listen_port_start + : undefined, + port_mappings: + isL4Mode && canSendPortMappings ? portMappings : undefined, targets: submittedTargets, enabled: reverseProxy?.enabled ?? true, pass_host_header: isL4Mode ? undefined : passHostHeader, @@ -637,7 +688,22 @@ export default function ReverseProxyModal({ {!reverseProxy && !isPrivate && ( { + const wasL4 = isL4ServiceMode(serviceMode); + setServiceMode(nextMode); + if (isL4ServiceMode(nextMode)) { + const protocol = + nextMode as ReverseProxyPortMapping["protocol"]; + setPortMappings((current) => { + if (!wasL4 || current.length === 0) { + return [emptyPortMapping(protocol)]; + } + return current.map((mapping, index) => + index === 0 ? { ...mapping, protocol } : mapping, + ); + }); + } + }} value={serviceMode} domain={selectedDomain} /> @@ -645,8 +711,8 @@ export default function ReverseProxyModal({ {isPrivate && accessGroups.length === 0 && ( - NetBird-only is on but no access groups are set. Open it - on the Authentication tab and pick at least one group. + NetBird-only is on but no access groups are set. Open it on + the Authentication tab and pick at least one group. )} @@ -654,11 +720,10 @@ export default function ReverseProxyModal({ - NetBird-Only Access requires a proxy cluster with - at least one connected embedded proxy ( + NetBird-Only Access requires a proxy cluster with at + least one connected embedded proxy ( netbird proxy). The selected cluster - doesn't have one. Connect an embedded proxy to - this cluster to enable this option. + doesn't have one. Connect an embedded proxy to this + cluster to enable this option.
} > @@ -804,9 +869,9 @@ export default function ReverseProxyModal({ /> } > - This service is accessible via NetBird only. An allow rule - for the NetBird network range is applied by default. Any - rules you add here are layered on top. + This service is accessible via NetBird only. An allow rule for + the NetBird network range is applied by default. Any rules you + add here are layered on top. )}
- {(serviceMode === ServiceMode.TCP || - serviceMode === ServiceMode.TLS) && ( - - - Preserve Client Source IP - - } - helpText="Preserve client source IP addresses when forwarding traffic to the backend using PROXY Protocol v2." + {isL4Mode && hasTCPMappings && ( + + + Preserve Client Source IP + + } + helpText="Preserve client source IP addresses when forwarding traffic to the backend using PROXY Protocol v2." + /> + )} + + {isL4Mode && hasTCPMappings && ( +
+
+ + + Timeout for establishing TCP/TLS backend connections. +
Leave this field empty for no timeout. +
+
+ } + placeholder="e.g. 10s, 30s, 1m" + value={connectionTimeoutOption} + onChange={(e) => setConnectionTimeoutOption(e.target.value)} + maxWidthClass="w-[180px]" + errorTooltip={true} + error={connectionTimeoutError} + data-testid="connection-timeout-input" /> +
)} - {isL4Mode && ( - <> -
-
- - - {serviceMode === ServiceMode.UDP ? ( - <> - Close the UDP session after this period of - inactivity. -
Leave this field empty for no timeout. - - ) : ( - <> - Timeout for establishing backend connections.
{" "} - Leave this field empty for no timeout. - - )} -
-
- } - placeholder="e.g. 10s, 30s, 1m" - value={timeoutOption} - onChange={(e) => setTimeoutOption(e.target.value)} - maxWidthClass="w-[180px]" - errorTooltip={true} - error={timeoutError} - data-testid="connection-timeout-input" - /> + {isL4Mode && hasUDPMappings && ( +
+
+ + + Close UDP sessions after this period of inactivity. +
Leave this field empty for no timeout. +
- + } + placeholder="e.g. 10s, 30s, 1m" + value={udpSessionTimeoutOption} + onChange={(e) => setUDPSessionTimeoutOption(e.target.value)} + maxWidthClass="w-[180px]" + errorTooltip={true} + error={udpSessionTimeoutError} + data-testid="udp-session-timeout-input" + /> +
)} {!isL4Mode && (
- - Pass Host Header - - } - helpText="Forward the original Host header to the backend instead of rewriting it to the target address." - /> + value={passHostHeader} + onChange={setPassHostHeader} + data-testid="toggle-pass-host-header" + label={ + <> + + Pass Host Header + + } + helpText="Forward the original Host header to the backend instead of rewriting it to the target address." + /> Date: Mon, 20 Jul 2026 20:01:46 -0400 Subject: [PATCH 2/3] dashboard: support shared-domain port mappings --- .../reverse-proxy-services-https.spec.ts | 101 ++++++++++++++++- .../ReverseProxyLayer4Content.tsx | 103 ++++++++++-------- .../reverse-proxy/ReverseProxyModal.tsx | 36 +++--- .../domain/useReverseProxyDomain.ts | 47 +++++++- 4 files changed, 222 insertions(+), 65 deletions(-) diff --git a/e2e/tests/reverse-proxy-services-https.spec.ts b/e2e/tests/reverse-proxy-services-https.spec.ts index 33a403d62..4c66c850a 100644 --- a/e2e/tests/reverse-proxy-services-https.spec.ts +++ b/e2e/tests/reverse-proxy-services-https.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "../helpers/fixtures"; import { navigateTo } from "../helpers/auth"; import { generateRandomName } from "../helpers/utils"; import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api"; -import { gotoReverseProxyPage, selectProxyDomain, CUSTOM_PORTS_DOMAIN } from "../helpers/reverse-proxy-l4"; +import { gotoReverseProxyPage, selectL4Resource, selectProxyDomain, CUSTOM_PORTS_DOMAIN } from "../helpers/reverse-proxy-l4"; let createdNetwork = ""; let createdResource = ""; @@ -124,6 +124,105 @@ test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => { await expect(page.locator("tr").filter({ hasText: subdomain })).toBeVisible({ timeout: 30_000 }); }); + test("Should reuse the HTTPS hostname for a non-conflicting TCP service", async ({ + dashboardAsOwner: page, + }) => { + test.setTimeout(60_000); + await gotoReverseProxyPage(page, "/reverse-proxy/services"); + + await page.getByTestId("add-service").first().click(); + await page.getByTestId("proxy-subdomain-input").fill(createdSubdomain); + await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN); + await expect(page.getByText("This domain is already used by another service.")).toBeVisible(); + + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-tls").click({ force: true }); + await expect(page.getByText("This domain is already used by another service.")).toBeVisible(); + + await page.getByTestId("service-mode-select-button").click({ force: true }); + await page.getByTestId("service-mode-option-tcp").click({ force: true }); + await expect(page.getByText("This domain is already used by another service.")).not.toBeVisible(); + + await selectL4Resource(page, createdResource); + await page.getByTestId("listen-port-input").fill("1773"); + await page.getByTestId("destination-port-input").fill("1773"); + await page.getByTestId("add-port-mapping").click(); + const secondMapping = page.getByTestId("port-mapping-1"); + await secondMapping.getByRole("combobox").click(); + await page.getByRole("option", { name: "TLS", exact: true }).click(); + await expect(page.getByText("This domain is already used by another service.")).toBeVisible(); + await secondMapping.getByRole("combobox").click(); + await page.getByRole("option", { name: "TCP", exact: true }).click(); + await expect(page.getByText("This domain is already used by another service.")).not.toBeVisible(); + + await page.getByTestId("listen-port-start-1").fill("1984"); + await page.getByTestId("listen-port-end-1").fill("1986"); + await page.getByTestId("destination-port-start-1").fill("1984"); + await page.getByTestId("destination-port-end-1").fill("1986"); + + const originalViewport = page.viewportSize() ?? { width: 1280, height: 720 }; + for (const width of [375, 768]) { + await page.setViewportSize({ width, height: 900 }); + const horizontalOverflows = await page + .locator('[data-testid^="port-mapping-"]') + .evaluateAll((cards) => + cards.map((card) => { + const bounds = card.getBoundingClientRect(); + return { + internal: card.scrollWidth - card.clientWidth, + left: bounds.left, + right: bounds.right, + viewport: window.innerWidth, + }; + }), + ); + expect( + horizontalOverflows.every( + ({ internal, left, right, viewport }) => + internal <= 1 && left >= -1 && right <= viewport + 1, + ), + ).toBe(true); + + const dialogBounds = await page.getByRole("dialog").boundingBox(); + expect(dialogBounds).not.toBeNull(); + expect(dialogBounds!.x).toBeGreaterThanOrEqual(-1); + expect(dialogBounds!.x + dialogBounds!.width).toBeLessThanOrEqual( + width + 1, + ); + } + await page.setViewportSize(originalViewport); + + await page.getByTestId("proxy-continue").click(); + await page.getByTestId("proxy-continue").click(); + + const createResponse = page.waitForResponse( + (response) => + response.url().includes("/api/reverse-proxies/services") && + response.request().method() === "POST", + { timeout: 15_000 }, + ); + await page.getByTestId("submit-service").click(); + await page.getByTestId("confirmation.confirm").click({ force: true }); + expect([200, 201]).toContain((await createResponse).status()); + + await resetServiceFilters(page); + const sharedDomainRows = page.locator("tr").filter({ hasText: createdSubdomain }); + await expect(sharedDomainRows).toHaveCount(2); + const tcpRow = sharedDomainRows.filter({ has: page.getByText("TCP", { exact: true }) }); + await expect(tcpRow).toHaveCount(1); + await tcpRow.getByTestId("service-actions").click({ force: true }); + await page.getByTestId("delete-service").click({ force: true }); + await page.getByTestId("confirmation.confirm").click({ force: true }); + await expect(sharedDomainRows).toHaveCount(1); + + const httpsRow = sharedDomainRows; + await httpsRow.getByTestId("service-actions").click({ force: true }); + await page.getByTestId("edit-service").click({ force: true }); + const httpsTargets = page.getByText("HTTPS Targets").locator(".."); + await expect(httpsTargets.locator("table tbody tr")).toHaveCount(2); + await page.getByTestId("modal-close").click(); + }); + test("Should edit the service, remove auth and rules, then delete", async ({ dashboardAsOwner: page, }) => { diff --git a/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx b/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx index ffe49b130..acc152ef1 100644 --- a/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx +++ b/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx @@ -222,21 +222,23 @@ export default function ReverseProxyLayer4Content({
-
+
- A hostname selects the proxy cluster. Raw UDP clients still - connect by address and port; UDP has no hostname routing on the - wire. + A hostname may also be used by an HTTPS service for TCP or UDP + mappings. TLS passthrough cannot share a hostname with an HTTPS + service. Raw UDP clients still connect by address and port; UDP + has no hostname routing on the wire.
+ + +
+
+ +
@@ -341,7 +389,7 @@ export default function ReverseProxyLayer4Content({
-
+
- -
- - - -
{errors[index]?.length > 0 && ( diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index 29d952437..a3e489c40 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -120,25 +120,15 @@ export default function ReverseProxyModal({ const { confirm } = useDialog(); const { handleCreateOrUpdateProxy } = useReverseProxies(); - const { - subdomain, - setSubdomain, - baseDomain, - setBaseDomain, - fullDomain, - domainAlreadyExists, - isClusterConnected, - } = useReverseProxyDomain({ reverseProxy, domains, initialSubdomain }); + const [serviceMode, setServiceMode] = useState( + reverseProxy?.mode ?? ServiceMode.HTTP, + ); const [tab, setTab] = useState(() => { if (initialTab && initialTab !== "") return initialTab; return "targets"; }); - const [serviceMode, setServiceMode] = useState( - reverseProxy?.mode ?? ServiceMode.HTTP, - ); - const isL4Mode = isL4ServiceMode(serviceMode); // L4 target selection state (TLS/TCP/UDP) - target is in targets[0] @@ -203,6 +193,22 @@ export default function ReverseProxyModal({ portMappings[0]?.protocol ?? (serviceMode as ReverseProxyPortMapping["protocol"]); + const { + subdomain, + setSubdomain, + baseDomain, + setBaseDomain, + fullDomain, + domainAlreadyExists, + isClusterConnected, + } = useReverseProxyDomain({ + reverseProxy, + domains, + initialSubdomain, + serviceMode, + portMappings, + }); + // The first mapping is the legacy mode/listener compatibility mirror. Keep // the selector, title, and protocol-specific settings aligned when mappings // are edited or reordered. @@ -627,7 +633,9 @@ export default function ReverseProxyModal({ return ( } diff --git a/src/modules/reverse-proxy/domain/useReverseProxyDomain.ts b/src/modules/reverse-proxy/domain/useReverseProxyDomain.ts index 25eca5c50..a44256821 100644 --- a/src/modules/reverse-proxy/domain/useReverseProxyDomain.ts +++ b/src/modules/reverse-proxy/domain/useReverseProxyDomain.ts @@ -1,8 +1,11 @@ import { useMemo, useState } from "react"; import { + isL4Mode, ReverseProxy, ReverseProxyDomain, ReverseProxyDomainType, + ReverseProxyPortMapping, + ServiceMode, } from "@/interfaces/ReverseProxy"; import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; @@ -74,12 +77,19 @@ type UseReverseProxyDomainOptions = { reverseProxy?: ReverseProxy; domains?: ReverseProxyDomain[]; initialSubdomain?: string; + serviceMode: ServiceMode; + portMappings: ReverseProxyPortMapping[]; }; +const canonicalDomain = (domain: string) => + domain.trim().toLowerCase().replace(/\.+$/, ""); + export function useReverseProxyDomain({ reverseProxy, domains, initialSubdomain, + serviceMode, + portMappings, }: UseReverseProxyDomainOptions) { const { reverseProxies } = useReverseProxies(); @@ -118,10 +128,39 @@ export function useReverseProxyDomain({ const domainAlreadyExists = useMemo(() => { if (!reverseProxies || !fullDomain) return false; - return reverseProxies.some( - (p) => p.domain === fullDomain && p.id !== reverseProxy?.id, - ); - }, [reverseProxies, fullDomain, reverseProxy?.id]); + const candidateDomain = canonicalDomain(fullDomain); + + const candidateIsL4 = isL4Mode(serviceMode); + const candidateHasTLS = + serviceMode === ServiceMode.TLS || + portMappings.some((mapping) => mapping.protocol === ServiceMode.TLS); + + // HTTP may share a hostname with raw TCP/UDP services. It retains one HTTP + // owner, while TLS passthrough remains exclusive because it also routes by + // hostname. Listener-range conflicts between L4 services are validated by + // management. + return reverseProxies.some((proxy) => { + if ( + canonicalDomain(proxy.domain) !== candidateDomain || + proxy.id === reverseProxy?.id + ) { + return false; + } + + const existingIsL4 = isL4Mode(proxy.mode); + const existingHasTLS = + proxy.mode === ServiceMode.TLS || + proxy.port_mappings?.some( + (mapping) => mapping.protocol === ServiceMode.TLS, + ) === true; + + return ( + (!candidateIsL4 && !existingIsL4) || + (!candidateIsL4 && existingHasTLS) || + (candidateHasTLS && !existingIsL4) + ); + }); + }, [reverseProxies, fullDomain, reverseProxy?.id, serviceMode, portMappings]); const isClusterConnected = useMemo(() => { if (!reverseProxy?.proxy_cluster) return false; From ea0fa062f9cbd0bb89111de29f43fa72ea4abcbd Mon Sep 17 00:00:00 2001 From: wander <127889726+heywander@users.noreply.github.com> Date: Tue, 21 Jul 2026 02:46:21 -0400 Subject: [PATCH 3/3] test: address multi-port review feedback --- .../reverse-proxy-services-https.spec.ts | 69 ++++++++++--------- e2e/tests/reverse-proxy-services-tcp.spec.ts | 2 +- .../reverse-proxy/ReverseProxyHTTPTargets.tsx | 2 +- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/e2e/tests/reverse-proxy-services-https.spec.ts b/e2e/tests/reverse-proxy-services-https.spec.ts index 4c66c850a..ca4402e27 100644 --- a/e2e/tests/reverse-proxy-services-https.spec.ts +++ b/e2e/tests/reverse-proxy-services-https.spec.ts @@ -70,7 +70,7 @@ test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => { port: 4433, }); - const targetsSection = page.getByText("HTTPS Targets").locator(".."); + const targetsSection = page.getByTestId("https-targets"); await expect(targetsSection.locator("table tbody tr")).toHaveCount(2); await page.getByTestId("proxy-continue").click(); @@ -148,10 +148,10 @@ test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => { await page.getByTestId("destination-port-input").fill("1773"); await page.getByTestId("add-port-mapping").click(); const secondMapping = page.getByTestId("port-mapping-1"); - await secondMapping.getByRole("combobox").click(); + await secondMapping.getByRole("combobox").click({ force: true }); await page.getByRole("option", { name: "TLS", exact: true }).click(); await expect(page.getByText("This domain is already used by another service.")).toBeVisible(); - await secondMapping.getByRole("combobox").click(); + await secondMapping.getByRole("combobox").click({ force: true }); await page.getByRole("option", { name: "TCP", exact: true }).click(); await expect(page.getByText("This domain is already used by another service.")).not.toBeVisible(); @@ -161,36 +161,39 @@ test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => { await page.getByTestId("destination-port-end-1").fill("1986"); const originalViewport = page.viewportSize() ?? { width: 1280, height: 720 }; - for (const width of [375, 768]) { - await page.setViewportSize({ width, height: 900 }); - const horizontalOverflows = await page - .locator('[data-testid^="port-mapping-"]') - .evaluateAll((cards) => - cards.map((card) => { - const bounds = card.getBoundingClientRect(); - return { - internal: card.scrollWidth - card.clientWidth, - left: bounds.left, - right: bounds.right, - viewport: window.innerWidth, - }; - }), + try { + for (const width of [375, 768]) { + await page.setViewportSize({ width, height: 900 }); + const horizontalOverflows = await page + .locator('[data-testid^="port-mapping-"]') + .evaluateAll((cards) => + cards.map((card) => { + const bounds = card.getBoundingClientRect(); + return { + internal: card.scrollWidth - card.clientWidth, + left: bounds.left, + right: bounds.right, + viewport: window.innerWidth, + }; + }), + ); + expect( + horizontalOverflows.every( + ({ internal, left, right, viewport }) => + internal <= 1 && left >= -1 && right <= viewport + 1, + ), + ).toBe(true); + + const dialogBounds = await page.getByRole("dialog").boundingBox(); + expect(dialogBounds).not.toBeNull(); + expect(dialogBounds!.x).toBeGreaterThanOrEqual(-1); + expect(dialogBounds!.x + dialogBounds!.width).toBeLessThanOrEqual( + width + 1, ); - expect( - horizontalOverflows.every( - ({ internal, left, right, viewport }) => - internal <= 1 && left >= -1 && right <= viewport + 1, - ), - ).toBe(true); - - const dialogBounds = await page.getByRole("dialog").boundingBox(); - expect(dialogBounds).not.toBeNull(); - expect(dialogBounds!.x).toBeGreaterThanOrEqual(-1); - expect(dialogBounds!.x + dialogBounds!.width).toBeLessThanOrEqual( - width + 1, - ); + } + } finally { + await page.setViewportSize(originalViewport); } - await page.setViewportSize(originalViewport); await page.getByTestId("proxy-continue").click(); await page.getByTestId("proxy-continue").click(); @@ -218,7 +221,7 @@ test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => { const httpsRow = sharedDomainRows; await httpsRow.getByTestId("service-actions").click({ force: true }); await page.getByTestId("edit-service").click({ force: true }); - const httpsTargets = page.getByText("HTTPS Targets").locator(".."); + const httpsTargets = page.getByTestId("https-targets"); await expect(httpsTargets.locator("table tbody tr")).toHaveCount(2); await page.getByTestId("modal-close").click(); }); @@ -231,7 +234,7 @@ test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => { await page.getByTestId("edit-service").click({ force: true }); // Edit first target - const targetsSection = page.getByText("HTTPS Targets").locator(".."); + const targetsSection = page.getByTestId("https-targets"); await targetsSection.locator("table tbody tr").first().click({ force: true }); await page.getByTestId("target-location-input").fill("/new-location"); await page.getByTestId("submit-target").click(); diff --git a/e2e/tests/reverse-proxy-services-tcp.spec.ts b/e2e/tests/reverse-proxy-services-tcp.spec.ts index 90b4b36a7..00de3dd7d 100644 --- a/e2e/tests/reverse-proxy-services-tcp.spec.ts +++ b/e2e/tests/reverse-proxy-services-tcp.spec.ts @@ -91,7 +91,7 @@ test.describe.serial("Reverse Proxy - Services (TCP) @reverse-proxy", () => { await page.getByTestId("add-port-mapping").click(); const udpMapping = page.getByTestId("port-mapping-2"); - await udpMapping.getByRole("combobox").click(); + await udpMapping.getByRole("combobox").click({ force: true }); await page.getByRole("option", { name: "UDP", exact: true }).click(); await page.getByTestId("listen-port-start-2").fill("3308"); await page.getByTestId("destination-port-start-2").fill("3308"); diff --git a/src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx b/src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx index 45226cbd7..55775d450 100644 --- a/src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx +++ b/src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx @@ -45,7 +45,7 @@ export default function ReverseProxyHTTPTargets({ onNavigateToResources, }: Readonly) { return ( -
+
Add one or more devices running your service or resources to make it