diff --git a/package-lock.json b/package-lock.json index e4d345e79..6ed766e26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "js-cookie": "^3.0.5", "lodash": "^4.17.23", "lucide-react": "^0.562.0", - "next": "^16.1.6", + "next": "^16.1.7", "next-themes": "^0.2.1", "punycode": "^2.3.1", "react": "^19.2.4", @@ -1213,9 +1213,9 @@ } }, "node_modules/@next/env": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", - "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz", + "integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1229,9 +1229,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", - "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz", + "integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==", "cpu": [ "arm64" ], @@ -1245,9 +1245,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", - "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz", + "integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==", "cpu": [ "x64" ], @@ -1261,9 +1261,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", - "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz", + "integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==", "cpu": [ "arm64" ], @@ -1277,9 +1277,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", - "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz", + "integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==", "cpu": [ "arm64" ], @@ -1293,9 +1293,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", - "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz", + "integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==", "cpu": [ "x64" ], @@ -1309,9 +1309,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", - "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz", + "integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==", "cpu": [ "x64" ], @@ -1325,9 +1325,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", - "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz", + "integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==", "cpu": [ "arm64" ], @@ -1341,9 +1341,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", - "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz", + "integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==", "cpu": [ "x64" ], @@ -5768,9 +5768,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "license": "ISC" }, "node_modules/for-each": { @@ -7067,14 +7067,14 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", - "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz", + "integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==", "license": "MIT", "dependencies": { - "@next/env": "16.1.6", + "@next/env": "16.1.7", "@swc/helpers": "0.5.15", - "baseline-browser-mapping": "^2.8.3", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -7086,14 +7086,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.1.6", - "@next/swc-darwin-x64": "16.1.6", - "@next/swc-linux-arm64-gnu": "16.1.6", - "@next/swc-linux-arm64-musl": "16.1.6", - "@next/swc-linux-x64-gnu": "16.1.6", - "@next/swc-linux-x64-musl": "16.1.6", - "@next/swc-win32-arm64-msvc": "16.1.6", - "@next/swc-win32-x64-msvc": "16.1.6", + "@next/swc-darwin-arm64": "16.1.7", + "@next/swc-darwin-x64": "16.1.7", + "@next/swc-linux-arm64-gnu": "16.1.7", + "@next/swc-linux-arm64-musl": "16.1.7", + "@next/swc-linux-x64-gnu": "16.1.7", + "@next/swc-linux-x64-musl": "16.1.7", + "@next/swc-win32-arm64-msvc": "16.1.7", + "@next/swc-win32-x64-msvc": "16.1.7", "sharp": "^0.34.4" }, "peerDependencies": { diff --git a/package.json b/package.json index c0d4d8341..fb876de84 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "js-cookie": "^3.0.5", "lodash": "^4.17.23", "lucide-react": "^0.562.0", - "next": "^16.1.6", + "next": "^16.1.7", "next-themes": "^0.2.1", "punycode": "^2.3.1", "react": "^19.2.4", diff --git a/src/assets/icons/ReverseProxyIcon.tsx b/src/assets/icons/ReverseProxyIcon.tsx index 4f989db34..8ce81b20c 100644 --- a/src/assets/icons/ReverseProxyIcon.tsx +++ b/src/assets/icons/ReverseProxyIcon.tsx @@ -8,8 +8,12 @@ export default function ReverseProxyIcon(props: IconProps) { viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...iconProperties(props)} + fill={"currentColor"} > - + ); } diff --git a/src/components/DeviceCard.tsx b/src/components/DeviceCard.tsx index f71461a07..9f0b74c6b 100644 --- a/src/components/DeviceCard.tsx +++ b/src/components/DeviceCard.tsx @@ -80,13 +80,15 @@ export const DeviceCard = ({ hideTooltip={true} /> - - - + {descriptionText && ( + + + + )} ); diff --git a/src/components/RadioCard.tsx b/src/components/RadioCard.tsx index 725c21e93..f5443cf93 100644 --- a/src/components/RadioCard.tsx +++ b/src/components/RadioCard.tsx @@ -8,6 +8,7 @@ type Props = { description: ReactNode; icon?: ReactNode; className?: string; + disabled?: boolean; }; export const RadioCard = ({ @@ -16,15 +17,18 @@ export const RadioCard = ({ description, className, icon, + disabled, }: Props) => { return ( diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 9fceafa21..78cca0d00 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -77,23 +77,56 @@ const SelectItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { extra?: React.ReactNode; + icon?: React.ReactNode; + description?: React.ReactNode; } ->(({ className, children, extra, ...props }, ref) => ( +>(({ className, children, extra, icon, description, ...props }, ref) => ( - - - - - - - {children} + {icon ? ( + <> + + + + + +
+ {icon} +
+ {children} + {description && ( + + {description} + + )} +
+
+ + ) : ( + <> + + + + + +
+ {children} + {description && ( + + {description} + + )} +
+ + )} {extra}
)); diff --git a/src/components/skeletons/SkeletonDeviceCard.tsx b/src/components/skeletons/SkeletonDeviceCard.tsx index 02b230080..cbdfbaff0 100644 --- a/src/components/skeletons/SkeletonDeviceCard.tsx +++ b/src/components/skeletons/SkeletonDeviceCard.tsx @@ -1,15 +1,20 @@ import * as React from "react"; import Skeleton from "react-loading-skeleton"; +import { cn } from "@utils/helpers"; -export const SkeletonDeviceCard = () => { +type Props = { + className?: string; +}; + +export const SkeletonDeviceCard = ({ className = "min-h-[59px]" }: Props) => { return ( -
-
- -
- - -
+
+ +
+ +
); diff --git a/src/contexts/ReverseProxiesProvider.tsx b/src/contexts/ReverseProxiesProvider.tsx index e1c38de81..845c50ad0 100644 --- a/src/contexts/ReverseProxiesProvider.tsx +++ b/src/contexts/ReverseProxiesProvider.tsx @@ -26,6 +26,8 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx type ReverseProxiesContextValue = { reverseProxies: ReverseProxy[] | undefined; + resources: NetworkResource[] | undefined; + peers: Peer[] | undefined; isLoading: boolean; openModal: (options?: OpenModalOptions) => void; openTargetModal: (options: OpenTargetModalOptions) => void; @@ -93,7 +95,7 @@ export default function ReverseProxiesProvider({ const { data: rawReverseProxies, isLoading } = useFetchApi( "/reverse-proxies/services", ); - const request = useApiCall("/reverse-proxies/services"); + const request = useApiCall("/reverse-proxies/services", true); // Peers & Resources for resolving target destinations const { data: peers } = useFetchApi("/peers"); @@ -465,6 +467,8 @@ export default function ReverseProxiesProvider({ ; + proxy_protocol?: boolean; } export interface ReverseProxyTarget { @@ -73,6 +85,7 @@ export interface ReverseProxyDomain { validated: boolean; type: ReverseProxyDomainType; target_cluster?: string; + supports_custom_ports?: boolean; } export enum ReverseProxyDomainType { @@ -90,6 +103,15 @@ export enum ReverseProxyTargetType { export enum ReverseProxyTargetProtocol { HTTP = "http", HTTPS = "https", + TCP = "tcp", + UDP = "udp", +} + +export enum EventProtocol { + HTTP = "http", + TCP = "tcp", + UDP = "udp", + TLS = "tls", } export interface ReverseProxyEvent { @@ -107,12 +129,31 @@ export interface ReverseProxyEvent { auth_method_used?: string; country_code?: string; city_name?: string; + bytes_upload: number; + bytes_download: number; + protocol?: EventProtocol; +} + +export function isL4Event(event: ReverseProxyEvent): boolean { + return ( + event.protocol === EventProtocol.TCP || + event.protocol === EventProtocol.UDP || + event.protocol === EventProtocol.TLS + ); } export interface ReverseProxyFlatTarget extends ReverseProxyTarget { proxy: ReverseProxy; } +export function isL4Mode(mode?: ServiceMode): boolean { + return ( + mode === ServiceMode.TCP || + mode === ServiceMode.UDP || + mode === ServiceMode.TLS + ); +} + export const REVERSE_PROXY_DOCS_LINK = "https://docs.netbird.io/manage/reverse-proxy"; @@ -139,3 +180,6 @@ export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK = export const REVERSE_PROXY_EVENTS_DOCS_LINK = "https://docs.netbird.io/manage/reverse-proxy/access-logs"; + +export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK = + "https://docs.netbird.io/manage/reverse-proxy#troubleshooting"; diff --git a/src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx b/src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx new file mode 100644 index 000000000..038e4d509 --- /dev/null +++ b/src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx @@ -0,0 +1,182 @@ +import Button from "@components/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; +import HelpText from "@components/HelpText"; +import { InlineButtonLink } from "@components/InlineLink"; +import { Label } from "@components/Label"; +import { ToggleSwitch } from "@components/ToggleSwitch"; +import { + AlertTriangle, + ArrowRight, + ArrowUpRight, + Edit, + MinusCircleIcon, + MoreVertical, + PlusIcon, +} from "lucide-react"; +import { Callout } from "@components/Callout"; +import React from "react"; +import { Network } from "@/interfaces/Network"; +import { ReverseProxyTarget } from "@/interfaces/ReverseProxy"; +import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; +import { cn } from "@utils/helpers"; + +type Props = { + targets: ReverseProxyTarget[]; + onEditTarget: (index: number) => void; + onRemoveTarget: (index: number) => void; + onToggleTargetEnabled: (index: number) => void; + onAddTarget: () => void; + initialNetwork?: Network; + onNavigateToResources?: () => void; +}; + +export default function ReverseProxyHTTPTargets({ + targets, + onEditTarget, + onRemoveTarget, + onToggleTargetEnabled, + onAddTarget, + initialNetwork, + onNavigateToResources, +}: Readonly) { + return ( +
+ + + Add one or more devices running your service or resources to make it + publicly accessible. + + + {targets.length > 0 && ( +
+ + + {targets.map((target, index) => ( + onEditTarget(index)} + className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all" + > + + + + + + ))} + +
+ + {target.path + ? target.path.startsWith("/") + ? target.path + : `/${target.path}` + : "/"} + + + + + + +
e.stopPropagation()} + > + onToggleTargetEnabled(index)} + /> + + + + + + onEditTarget(index)} + > +
+ + Edit Target +
+
+ onRemoveTarget(index)} + > +
+ + Remove Target +
+
+
+
+
+
+
+ )} + + + + {initialNetwork && !initialNetwork.resources?.length && ( + + } + > + There are currently no resources in your network{" "} + + {initialNetwork?.name} + + . Add resources to your network before exposing it as a service.{" "} + + Go to Resources + + + + )} +
+ ); +} + +function TargetDestination({ target }: { target: ReverseProxyTarget }) { + const { resolveDestination } = useReverseProxies(); + return ( + + {resolveDestination(target)} + + ); +} diff --git a/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx b/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx new file mode 100644 index 000000000..7b86ce829 --- /dev/null +++ b/src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx @@ -0,0 +1,133 @@ +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { ArrowRight } from "lucide-react"; +import React, { useRef } from "react"; +import { Network, NetworkResource } from "@/interfaces/Network"; +import { Peer } from "@/interfaces/Peer"; +import ReverseProxyAddressInput, { + CidrHelpText, +} from "@/modules/reverse-proxy/targets/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; + initialResource?: NetworkResource; + initialPeer?: Peer; + initialNetwork?: Network; +}; + +export default function ReverseProxyLayer4Content({ + l4Target, + setL4Target, + isListenPortSupported, + listenPort, + setListenPort, + port, + setPort, + initialResource, + initialPeer, + initialNetwork, +}: Readonly) { + const listenPortRef = useRef(null); + const portRef = useRef(null); + + return ( +
+ {!initialResource && !initialPeer && ( + { + setL4Target(selection); + if (selection) { + setTimeout(() => { + if (isListenPortSupported) { + listenPortRef.current?.focus(); + } else { + portRef.current?.focus(); + } + }, 0); + } + }} + /> + )} + +
+
+ +
+ setListenPort(parseInt(e.target.value) || 0)} + disabled={!isListenPortSupported || !l4Target} + aria-label="Public listen port" + /> +
+
+ +
+
+ +
+ +
+
+
+ +
+ setPort(parseInt(e.target.value) || 0)} + disabled={!l4Target} + aria-label="Destination port" + className={"rounded-l-none"} + /> +
+
+
+
+
+ ); +} diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index b7ef4b27d..3fd3c3616 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -1,15 +1,9 @@ "use client"; import Button from "@components/Button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@components/DropdownMenu"; import FancyToggleSwitch from "@components/FancyToggleSwitch"; import HelpText from "@components/HelpText"; -import InlineLink, { InlineButtonLink } from "@components/InlineLink"; +import InlineLink from "@components/InlineLink"; import { Input } from "@components/Input"; import { Label } from "@components/Label"; import SettingCard from "@components/SettingCard"; @@ -22,27 +16,19 @@ import { import Paragraph from "@components/Paragraph"; import ModalHeader from "@components/modal/ModalHeader"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs"; -import { ToggleSwitch } from "@components/ToggleSwitch"; import { - AlertTriangle, ArrowRight, - ArrowUpRight, Binary, - Edit, + ClockFadingIcon, ExternalLinkIcon, GlobeIcon, LockKeyhole, - MinusCircleIcon, - MoreVertical, + MapPinned, PlusCircle, - PlusIcon, RectangleEllipsis, - Server, Settings, - Text, Users, } from "lucide-react"; -import { Callout } from "@components/Callout"; import { useRouter } from "next/navigation"; import React, { useMemo, useState } from "react"; import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; @@ -51,23 +37,38 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import { Network, NetworkResource } from "@/interfaces/Network"; import { Peer } from "@/interfaces/Peer"; import { + isL4Mode as isL4ServiceMode, REVERSE_PROXY_AUTHENTICATION_DOCS_LINK, REVERSE_PROXY_SERVICES_DOCS_LINK, REVERSE_PROXY_SETTINGS_DOCS_LINK, ReverseProxy, ReverseProxyAuth, ReverseProxyDomain, - ReverseProxyDomainType, ReverseProxyTarget, + ReverseProxyTargetProtocol, + ReverseProxyTargetType, + ServiceMode, } from "@/interfaces/ReverseProxy"; -import { CustomDomainSelector } from "./domain/CustomDomainSelector"; -import { cn } from "@utils/helpers"; +import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; +import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput"; +import { useReverseProxyDomain } from "./domain/useReverseProxyDomain"; import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal"; import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal"; import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal"; +import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets"; +import ReverseProxyLayer4Content 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"; +import { + validateTimeout, + validateSessionIdleTimeout, +} from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions"; import useGroupHelper from "@/modules/groups/useGroupHelper"; -import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; +import { + ReverseProxyServiceModeSelector, + SERVICE_MODES, +} from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector"; type Props = { open: boolean; @@ -84,41 +85,6 @@ type Props = { onSuccess?: () => void; }; -// Helper to parse domain into subdomain and base domain -function parseDomain(fullDomain: string): { - subdomain: string; - baseDomain: string; - isCustom: boolean; -} { - const knownDomains = ["netbird.cloud", "netbird.io", "netbird.app"]; - - for (const known of knownDomains) { - if (fullDomain.endsWith(`.${known}`)) { - return { - subdomain: fullDomain.slice(0, -(known.length + 1)), - baseDomain: known, - isCustom: false, - }; - } - } - - // Custom domain - find the first dot to split - const firstDot = fullDomain.indexOf("."); - if (firstDot > 0) { - return { - subdomain: fullDomain.slice(0, firstDot), - baseDomain: fullDomain.slice(firstDot + 1), - isCustom: true, - }; - } - - return { - subdomain: fullDomain, - baseDomain: "netbird.cloud", - isCustom: false, - }; -} - export default function ReverseProxyModal({ open, onOpenChange, @@ -134,51 +100,112 @@ export default function ReverseProxyModal({ const router = useRouter(); const { permission } = usePermissions(); const { confirm } = useDialog(); - const { reverseProxies, handleCreateOrUpdateProxy } = useReverseProxies(); - - // Check if the proxy's cluster exists in available free domains - const isClusterConnected = useMemo(() => { - if (!reverseProxy?.proxy_cluster) return false; - return domains?.some( - (d) => - d.type === ReverseProxyDomainType.FREE && - d.domain === reverseProxy.proxy_cluster, - ); - }, [reverseProxy?.proxy_cluster, domains]); + const { handleCreateOrUpdateProxy } = useReverseProxies(); + + const { + subdomain, + setSubdomain, + baseDomain, + setBaseDomain, + fullDomain, + domainAlreadyExists, + isClusterConnected, + } = useReverseProxyDomain({ reverseProxy, domains, initialSubdomain }); const [tab, setTab] = useState(() => { if (initialTab && initialTab !== "") return initialTab; return "targets"; }); - // Parse existing domain if editing - const parsed = reverseProxy?.domain ? parseDomain(reverseProxy.domain) : null; + const [serviceMode, setServiceMode] = useState( + reverseProxy?.mode ?? ServiceMode.HTTP, + ); - // Form state - const [subdomain, setSubdomain] = useState( - parsed?.subdomain || - initialSubdomain - ?.toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^a-z0-9-]/g, "") || + const isL4Mode = isL4ServiceMode(serviceMode); + + // L4 target selection state (TLS/TCP/UDP) - target is in targets[0] + const [l4Target, setL4Target] = useState(() => { + const existing = isL4ServiceMode(reverseProxy?.mode) + ? reverseProxy?.targets?.[0] + : undefined; + if (existing) { + const isPeer = existing.target_type === ReverseProxyTargetType.PEER; + return { + type: existing.target_type, + peerId: isPeer ? existing.target_id : undefined, + resourceId: isPeer ? undefined : existing.target_id, + host: existing.host || "", + }; + } + if (initialResource) { + const addr = initialResource.address; + return { + type: + (initialResource.type as ReverseProxyTargetType) ?? + ReverseProxyTargetType.HOST, + resourceId: initialResource.id, + host: addr.includes("/") ? addr.split("/")[0] : addr, + }; + } + if (initialPeer) { + return { + type: ReverseProxyTargetType.PEER, + peerId: initialPeer.id, + host: initialPeer.ip, + }; + } + return undefined; + }); + + const [port, setPort] = useState( + reverseProxy?.targets?.[0]?.port || 0, + ); + + const [listenPort, setListenPort] = useState( + reverseProxy?.listen_port || 0, + ); + + // CIDR detection for L4 subnet resources + const { isCidrRange: l4IsCidrRange, isValidCidrHost: l4IsValidCidrHost } = + useReverseProxyAddress(l4Target); + + // Proxy protocol: for L4 modes maps to target proxy_protocol + const [proxyProtocol, setProxyProtocol] = useState( + 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 [baseDomain, setBaseDomain] = useState(() => { - if (parsed?.baseDomain) return parsed.baseDomain; - const validatedDomains = domains?.filter((d) => d.validated) || []; - const customDomain = validatedDomains.find( - (d) => d.type === ReverseProxyDomainType.CUSTOM, - ); - const freeDomain = validatedDomains.find( - (d) => d.type === ReverseProxyDomainType.FREE, - ); - return customDomain?.domain || freeDomain?.domain || ""; - }); + const timeoutError = useMemo(() => { + if (!timeoutOption) return undefined; + return serviceMode === ServiceMode.UDP + ? validateSessionIdleTimeout(timeoutOption) + : validateTimeout(timeoutOption); + }, [timeoutOption, serviceMode]); const [targets, setTargets] = useState( reverseProxy?.targets || [], ); + + const selectedDomain = useMemo( + () => + domains?.find( + (d) => d.domain === baseDomain || d.target_cluster === baseDomain, + ), + [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 [passHostHeader, setPassHostHeader] = useState( reverseProxy?.pass_host_header ?? false, ); @@ -186,19 +213,6 @@ export default function ReverseProxyModal({ reverseProxy?.rewrite_redirects ?? false, ); - // Compute full domain - const fullDomain = useMemo(() => { - if (!baseDomain) return subdomain; - return `${subdomain}.${baseDomain}`; - }, [subdomain, baseDomain]); - - const domainAlreadyExists = useMemo(() => { - if (!reverseProxies || !fullDomain) return false; - return reverseProxies.some( - (p) => p.domain === fullDomain && p.id !== reverseProxy?.id, - ); - }, [reverseProxies, fullDomain, reverseProxy?.id]); - // Authentication options - initialized from existing reverseProxy.auth const [passwordEnabled, setPasswordEnabled] = useState( reverseProxy?.auth?.password_auth?.enabled ?? false, @@ -233,19 +247,32 @@ export default function ReverseProxyModal({ null, ); - const isSubdomainValid = useMemo(() => { - return ( - subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists - ); - }, [subdomain, baseDomain, domainAlreadyExists]); - const canContinueToSettings = useMemo(() => { - return isSubdomainValid && targets.length > 0; - }, [isSubdomainValid, targets]); - - const submitDisabled = useMemo(() => { - return !canContinueToSettings; - }, [canContinueToSettings]); + const isSubdomainValid = + subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists; + const isValidPort = (port: number) => port >= 1 && port <= 65535; + const hasHttpEndpoint = !isL4Mode && targets.length > 0; + const hasL4Endpoint = + isL4Mode && + !!l4Target && + l4IsValidCidrHost && + isValidPort(port) && + (!isListenPortSupported || isValidPort(listenPort)); + const hasAnyEndpoint = hasHttpEndpoint || hasL4Endpoint; + return isSubdomainValid && hasAnyEndpoint; + }, [ + subdomain, + baseDomain, + domainAlreadyExists, + serviceMode, + targets.length, + isL4Mode, + l4Target, + l4IsValidCidrHost, + port, + isListenPortSupported, + listenPort, + ]); const saveTarget = (targetData: ReverseProxyTarget) => { if (editingTargetIndex !== null) { @@ -282,8 +309,8 @@ export default function ReverseProxyModal({ !passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled; const handleSubmit = async () => { - // Show warning if no authentication is configured - if (hasNoAuth) { + // Show warning if no authentication is configured (HTTP only; TLS is pass-through) + if (!isL4Mode && hasNoAuth) { const confirmed = await confirm({ title: "No Authentication Configured", description: @@ -316,15 +343,46 @@ 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; + handleCreateOrUpdateProxy({ data: { name: fullDomain, domain: fullDomain, - targets, + mode: isL4Mode ? (serviceMode as ServiceMode) : undefined, + listen_port: isL4Mode && isListenPortSupported ? listenPort : undefined, + targets: isL4Mode && l4TargetPayload ? [l4TargetPayload] : targets, enabled: reverseProxy?.enabled ?? true, - pass_host_header: passHostHeader, - rewrite_redirects: rewriteRedirects, - auth, + pass_host_header: isL4Mode ? undefined : passHostHeader, + rewrite_redirects: isL4Mode ? undefined : rewriteRedirects, + auth: isL4Mode ? undefined : auth, }, proxyId: reverseProxy?.id, onSuccess: () => { @@ -334,6 +392,20 @@ export default function ReverseProxyModal({ }); }; + const modalTitle = useMemo(() => { + const prefix = reverseProxy ? "Edit" : "Add"; + const label = serviceMode ? SERVICE_MODES[serviceMode].label : "Service"; + return `${prefix} ${label}`; + }, [reverseProxy, serviceMode]); + + const modalDescription = useMemo( + () => + isL4Mode + ? "Forward traffic directly to your backend service." + : "Expose services securely through NetBird's reverse proxy.", + [isL4Mode], + ); + return ( } - title={reverseProxy ? "Edit Service" : "Add Service"} - description={ - "Expose services securely through NetBird's reverse proxy." - } + title={modalTitle} + description={modalDescription} color={"netbird"} /> - - Details - - - - Authentication + + Service + {!isL4Mode && ( + + + Authentication + + )} Advanced Settings @@ -366,199 +438,56 @@ export default function ReverseProxyModal({
-
- - - Enter a subdomain and select a domain for your service. - -
-
- { - setSubdomain( - e.target.value - .toLowerCase() - .replace(/[^a-z0-9-]/g, ""), - ); - }} - error={ - domainAlreadyExists - ? "This domain is already used by another service." - : undefined - } - placeholder={"myapp"} - className="!rounded-r-none !border-r-0" - /> -
-
- -
-
-
- - {reverseProxy?.proxy_cluster && !isClusterConnected && ( - - Cluster {reverseProxy.proxy_cluster} is offline. Make sure the - proxy server is running and connected to the right management - address. - - )} - -
- - - Add one or more devices running your service or resources to - make it publicly accessible. - - - {targets.length > 0 && ( -
- - - {targets.map((target, index) => ( - editTarget(index)} - className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all" - > - - - - - - ))} - -
- - {target.path - ? target.path.startsWith("/") - ? target.path - : `/${target.path}` - : "/"} - - - - - - -
e.stopPropagation()} - > - - toggleTargetEnabled(index) - } - /> - - - - - - editTarget(index)} - > -
- - Edit Target -
-
- removeTarget(index)} - > -
- - Remove Target -
-
-
-
-
-
-
- )} + - + {!reverseProxy && ( + + )} - {initialNetwork && !initialNetwork.resources?.length && ( - - } - > - There are currently no resources in your network{" "} - - {initialNetwork?.name} - - . Add resources to your network before exposing it as a - service.{" "} - { - onOpenChange(false); - router.push( - `/network?id=${initialNetwork.id}&tab=resources`, - ); - }} - > - Go to Resources - - - - )} -
+ {isL4Mode ? ( + + ) : ( + setTargetModalOpen(true)} + initialNetwork={initialNetwork} + onNavigateToResources={() => { + onOpenChange(false); + router.push( + `/network?id=${initialNetwork?.id}&tab=resources`, + ); + }} + /> + )}
@@ -603,73 +532,116 @@ export default function ReverseProxyModal({ -
- - - Pass Host Header - - } - helpText="Forward the original Host header to the backend instead of rewriting it to the target address." - /> - - - Rewrite Redirects - - } - helpText="Rewrite Location headers in backend responses to use the public domain instead of the internal backend address." - /> +
+ {(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 && ( + <> +
+
+ + + {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} + /> +
+ + )} + + {!isL4Mode && ( +
+ + + Pass Host Header + + } + helpText="Forward the original Host header to the backend instead of rewriting it to the target address." + /> + + + Rewrite Redirects + + } + helpText="Rewrite Location headers in backend responses to use the public domain instead of the internal backend address." + /> +
+ )}
- {tab === "targets" && ( - - Learn more about - - Services - - - - )} - - {tab === "auth" && ( - - Learn more about - - Authentication - - - - )} - - {tab === "settings" && ( - - Learn more about - - Settings - - - - )} + {(() => { + const docsLink = { + targets: { + href: REVERSE_PROXY_SERVICES_DOCS_LINK, + label: "Services", + }, + auth: { + href: REVERSE_PROXY_AUTHENTICATION_DOCS_LINK, + label: "Authentication", + }, + settings: { + href: REVERSE_PROXY_SETTINGS_DOCS_LINK, + label: "Settings", + }, + }[tab]; + return docsLink ? ( + + Learn more about + + {docsLink.label} + + + + ) : null; + })()}
{!reverseProxy ? ( @@ -681,7 +653,7 @@ export default function ReverseProxyModal({ - - - ) : ( - <> - {tab === "details" && ( - <> - - - - )} - {tab === "options" && ( - <> - - - - )} - - )} + +
- - - - ); } diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx new file mode 100644 index 000000000..da59fa480 --- /dev/null +++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx @@ -0,0 +1,151 @@ +"use client"; + +import HelpText from "@components/HelpText"; +import { Label } from "@components/Label"; +import { Modal } from "@components/modal/Modal"; +import { PeerGroupSelector } from "@components/PeerGroupSelector"; +import React, { useState } from "react"; +import { Network } from "@/interfaces/Network"; +import { ReverseProxyTargetType } from "@/interfaces/ReverseProxy"; +import { + isResourceTargetType, + useReverseProxies, +} from "@/contexts/ReverseProxiesProvider"; +import { HelpTooltip } from "@components/HelpTooltip"; +import InlineLink, { InlineButtonLink } from "@components/InlineLink"; +import SetupModal from "@/modules/setup-netbird-modal/SetupModal"; + +export type Target = { + type: ReverseProxyTargetType; + peerId?: string; + resourceId?: string; + host: string; +}; + +type Props = { + value?: Target; + initialNetwork?: Network; + onChange: (value: Target | undefined) => void; +}; + +export default function ReverseProxyTargetSelector({ + value, + initialNetwork, + onChange, +}: Readonly) { + const { resources, peers } = useReverseProxies(); + const [installModal, setInstallModal] = useState(false); + + return ( +
+ + + {initialNetwork + ? "Select the resource from your network you want to expose." + : "Select the peer or resource where your service is running."} + + {}} + placeholder={ + initialNetwork + ? "Select a resource..." + : "Select a peer or resource..." + } + showPeers={!initialNetwork} + showResources={true} + showRoutes={false} + hideAllGroup={true} + hideGroupsTab={true} + resourceIds={ + initialNetwork ? initialNetwork.resources ?? [] : undefined + } + tabOrder={initialNetwork ? ["resources"] : ["peers", "resources"]} + closeOnSelect={true} + max={1} + resource={ + value?.type && isResourceTargetType(value.type) && value.resourceId + ? { id: value.resourceId, type: value.type } + : value?.type === ReverseProxyTargetType.PEER && value.peerId + ? { id: value.peerId, type: "peer" } + : undefined + } + onResourceChange={(res) => { + if (res) { + if (res.type === "peer") { + const peer = peers?.find((p) => p.id === res.id); + onChange({ + type: ReverseProxyTargetType.PEER, + peerId: res.id, + host: peer?.ip || "localhost", + }); + } else { + const selectedResource = resources?.find((r) => r.id === res.id); + const address = selectedResource?.address || ""; + onChange({ + type: + (selectedResource?.type as ReverseProxyTargetType) ?? + ReverseProxyTargetType.HOST, + resourceId: res.id, + host: address.includes("/") ? address.split("/")[0] : address, + }); + } + } else { + onChange(undefined); + } + }} + /> + + + +
+ ); +} diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetsTable.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetsTable.tsx index e7dc5a0da..063cd6733 100644 --- a/src/modules/reverse-proxy/targets/ReverseProxyTargetsTable.tsx +++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetsTable.tsx @@ -74,6 +74,7 @@ export default function ReverseProxyTargetsTable({ reverseProxy }: Props) { className={"bg-nb-gray-960 py-2"} inset={true} text={"Targets"} + initialPageSize={reverseProxy?.targets?.length} manualPagination={true} sorting={sorting} columnVisibility={{}} diff --git a/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetActionCell.tsx b/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetActionCell.tsx index cfdc14909..a7f8c625a 100644 --- a/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetActionCell.tsx +++ b/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetActionCell.tsx @@ -9,7 +9,7 @@ import { MoreVertical, Settings, SquarePenIcon, Trash2 } from "lucide-react"; import * as React from "react"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useReverseProxies } from "@/contexts/ReverseProxiesProvider"; -import { ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy"; +import { isL4Mode, ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy"; type Props = { target: ReverseProxyFlatTarget; @@ -40,7 +40,11 @@ export default function ReverseProxyFlatTargetActionCell({ { e.stopPropagation(); - openTargetModal({ proxy: target.proxy, target: target }); + if (isL4Mode(target.proxy.mode)) { + openModal({ proxy: target.proxy }); + } else { + openTargetModal({ proxy: target.proxy, target: target }); + } }} disabled={!permission?.services?.update} > @@ -57,9 +61,9 @@ export default function ReverseProxyFlatTargetActionCell({ }} disabled={!permission?.services?.update} > -
+
- Settings + Advanced Settings
diff --git a/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTable.tsx b/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTable.tsx index dd1ea28a2..3f6861cf5 100644 --- a/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTable.tsx +++ b/src/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTable.tsx @@ -44,14 +44,15 @@ const FlatTargetsTableColumns: ColumnDef[] = [ : `/${target.path}` : ""; const fullUrl = `${target.proxy.domain}${path}`; - const disabled = target.enabled === false; - const isEnabled = target.proxy.enabled && target.enabled !== false; + const disabled = !target.enabled; + const isEnabled = target.proxy.enabled && target.enabled; return (
@@ -62,7 +63,7 @@ const FlatTargetsTableColumns: ColumnDef[] = [ accessorKey: "arrow", header: "", cell: ({ row }) => ( - + ), }, { @@ -120,7 +121,13 @@ const FlatTargetsTableColumns: ColumnDef[] = [ { id: "searchString", accessorFn: (row) => { - return [row.proxy.domain, row.destination, row.host, row.port, row.path].join(""); + return [ + row.proxy.domain, + row.destination, + row.host, + row.port, + row.path, + ].join(""); }, }, ]; diff --git a/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts index 79f797127..75dd70b1a 100644 --- a/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts +++ b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts @@ -8,6 +8,7 @@ import { // Go time.ParseDuration format: one or more {number}{unit} pairs const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/; const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m +const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m function parseDurationMs(duration: string): number { const units: Record = { @@ -28,7 +29,7 @@ function parseDurationMs(duration: string): number { return total; } -function validateTimeout(timeout: string): string | undefined { +export function validateTimeout(timeout: string): string | undefined { if (!timeout) return undefined; if (!DURATION_RE.test(timeout)) return 'Invalid duration, use e.g., "10s", "30s", "1m"'; @@ -37,6 +38,15 @@ function validateTimeout(timeout: string): string | undefined { return undefined; } +export function validateSessionIdleTimeout(timeout: string): string | undefined { + if (!timeout) return undefined; + if (!DURATION_RE.test(timeout)) + return 'Invalid duration, use e.g., "30s", "2m", "5m"'; + if (parseDurationMs(timeout) > MAX_SESSION_IDLE_TIMEOUT_MS) + return "Session idle timeout cannot exceed the maximum of 10m."; + return undefined; +} + export function useReverseProxyTargetOptions( initialOptions?: ServiceTargetOptions, ) { @@ -67,7 +77,11 @@ export function useReverseProxyTargetOptions( ); const timeoutError = validateTimeout(targetOptions.request_timeout ?? ""); - const hasOptionsErrors = !!timeoutError || hasHeaderErrors; + const sessionIdleTimeoutError = validateSessionIdleTimeout( + targetOptions.session_idle_timeout ?? "", + ); + const hasOptionsErrors = + !!timeoutError || !!sessionIdleTimeoutError || hasHeaderErrors; const getTargetOptions = useCallback((): ServiceTargetOptions | undefined => { const customHeaders = headerEntriesToRecord(headerEntries); @@ -94,6 +108,7 @@ export function useReverseProxyTargetOptions( }, errors: { timeout: timeoutError, + sessionIdleTimeout: sessionIdleTimeoutError, options: hasOptionsErrors, }, }, diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index b212fa90f..fcddc9258 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -257,3 +257,16 @@ export const singularize = ( } return count + " " + word; }; + +/** + * Converts milliseconds to human-readable duration (ms, s, m) + * @param ms Duration in milliseconds + * @returns Formatted string with appropriate unit + */ +export const formatDuration = (ms: number): string => { + if (!Number.isFinite(ms) || ms < 0) return "0ms"; + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`; + return `${(ms / 3600000).toFixed(1)}h`; +};