Compare commits

..
3 Commits
Author SHA1 Message Date
Viktor LiuandEduard Gert aff2365ef7 Add layer 4 protocol support to reverse proxy (#579)
* Add layer 4 proto support

* Fix initialResource fallback and UDP session_idle_timeout

* Fix tlsResourceId init for resource-driven create flows, UDP timeout label

* Address PR review: ServiceMode enum, resource init fix, modal title, a11y

* Add L4 protocol values to ReverseProxyTargetProtocol, remove unsafe double cast

* Add aria-labels to L4 port/host inputs

* Unify domain input for all service modes including L4

* Support L4 proxy events

* Fix custom port reset on edit and show port in L4 service link

* Remove redundant listen port from L4 target cell

* Show link only for HTTP/TLS services, copy-on-click for TCP/UDP

* Move mode badge before domain and use fixed width for alignment

* Fix HTTP services to open as link instead of copy

* Hide old proxy clusters from L4 domain selector

* Move service type inside modal

* Update auth cell

* Add target selector component

* Extract into separate components

* hide services types for not supported clusters

* Remove advanced settings tab in http targetmodal and use accordion instead

* Update advanced settings

* Update target device row

* Update text

* Add type cell

* Fix flat target name cell

* Update modal title

* Fix edit target in flat table

* Remove unused proxycluster interface

* Move proxy type icon into type component

* sync cloud

* use emptyrow

* fix l4 type

* fix duplicate error notification

* Set the correct target type

* Fix subnet host editable

* Fix subnet host editable

* hide selector when initial resource or peer

* Rename dropdown

* Update text

* update status cell

* merge cloud

* Update tooltips

* Address coderabbit comments

* Fix skeleton device card

* Update listen port tooltip

* Adjust padding

* update package-lock.json

* bump next to 16.1.7

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-18 17:43:00 +01:00
Zoltan PappandEduard Gert bad057d415 [dashboard] feat: add auto_update_always toggle to client settings (#580)
* [dashboard] feat: add auto_update_always toggle to client settings

Add "Always Update" toggle to the Clients settings tab that controls
whether updates are installed automatically in the background or require
user interaction from the UI. Includes a warning icon and caution callout
when enabled to highlight the risk of disrupting active connections.

* [dashboard] fix: improve auto-update UI clarity and toggle label

Clarify that automatic updates require user interaction by updating the
description. Rename "Always Update" to "Force Automatic Updates" for
clarity. Move warning callout below the toggle switch instead of inside it.

* Update src/modules/settings/ClientSettingsTab.tsx

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-16 15:35:44 +01:00
Misha Bragin 4d846e2c94 Improve text for optional resource setiings (#584) 2026-03-12 20:48:09 +01:00
41 changed files with 2332 additions and 1083 deletions

No files matched your search

+44 -44
View File
@@ -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": {
+1 -1
View File
@@ -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",
+5 -1
View File
@@ -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"}
>
<path d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z" />
<path
fill={"currentColor"}
d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z"
/>
</svg>
);
}
+9 -7
View File
@@ -80,13 +80,15 @@ export const DeviceCard = ({
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
{descriptionText && (
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
)}
</div>
</div>
);
+4
View File
@@ -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 (
<RadioGroup.Item
value={value}
disabled={disabled}
className={cn(
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
"hover:bg-nb-gray-930",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
className,
)}
>
+42 -9
View File
@@ -77,23 +77,56 @@ const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
extra?: React.ReactNode;
icon?: React.ReactNode;
description?: React.ReactNode;
}
>(({ className, children, extra, ...props }, ref) => (
>(({ className, children, extra, icon, description, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
"relative flex w-full select-none items-center rounded-md py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
icon ? "pl-2 pr-8" : "pl-8 pr-2",
description && "py-2",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{icon ? (
<>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex items-center gap-3">
<span className="flex-shrink-0">{icon}</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</div>
</>
) : (
<>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</>
)}
{extra}
</SelectPrimitive.Item>
));
@@ -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 (
<div className={"min-h-[59px] relative -left-2"}>
<div className={"py-2 pr-4 pl-2 flex gap-3"}>
<Skeleton height={36} width={36} />
<div className={"flex flex-col pr-[1.15rem]"}>
<Skeleton height={16} width={70} />
<Skeleton height={16} width={140} />
</div>
<div
className={cn("py-2 pr-4 pl-2 flex gap-3 relative -left-2", className)}
>
<Skeleton height={36} width={36} />
<div className={"flex flex-col pr-[1.15rem]"}>
<Skeleton height={16} width={70} />
<Skeleton height={16} width={140} />
</div>
</div>
);
+5 -1
View File
@@ -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<ReverseProxy[]>(
"/reverse-proxies/services",
);
const request = useApiCall<ReverseProxy>("/reverse-proxies/services");
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
// Peers & Resources for resolving target destinations
const { data: peers } = useFetchApi<Peer[]>("/peers");
@@ -465,6 +467,8 @@ export default function ReverseProxiesProvider({
<ReverseProxiesContext.Provider
value={{
reverseProxies,
resources,
peers,
isLoading,
openModal,
openTargetModal,
+1
View File
@@ -26,6 +26,7 @@ export interface Account {
lazy_connection_enabled: boolean;
embedded_idp_enabled?: boolean;
auto_update_version: string;
auto_update_always: boolean;
local_auth_disabled?: boolean;
};
onboarding?: AccountOnboarding;
+44
View File
@@ -1,7 +1,17 @@
export enum ServiceMode {
HTTP = "http",
TCP = "tcp",
UDP = "udp",
TLS = "tls",
}
export interface ReverseProxy {
id?: string;
name: string;
domain: string;
mode?: ServiceMode;
listen_port?: number;
port_auto_assigned?: boolean;
proxy_cluster?: string;
targets: ReverseProxyTarget[];
enabled: boolean;
@@ -31,8 +41,10 @@ export type ServiceTargetOptionsPathRewrite = "preserve";
export interface ServiceTargetOptions {
skip_tls_verify?: boolean;
request_timeout?: string;
session_idle_timeout?: string;
path_rewrite?: ServiceTargetOptionsPathRewrite;
custom_headers?: Record<string, string>;
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";
@@ -309,7 +309,7 @@ export function ResourceModalContent({
}
>
<span className={"relative top-[1px]"}>
Additional Options (optional)
Optional Settings
</span>
</AccordionTrigger>
<AccordionContent className={""}>
@@ -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<Props>) {
return (
<div>
<Label>HTTP/S Targets</Label>
<HelpText>
Add one or more devices running your service or resources to make it
publicly accessible.
</HelpText>
{targets.length > 0 && (
<div
className={
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md "
}
>
<table className="w-full">
<tbody>
{targets.map((target, index) => (
<tr
key={index}
onClick={() => onEditTarget(index)}
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
>
<td className="py-2.5 pl-5 pr-2 align-middle">
<span className="text-[11px] leading-none font-mono px-2.5 py-2 rounded bg-nb-gray-900 text-nb-gray-300 inline-flex items-center">
{target.path
? target.path.startsWith("/")
? target.path
: `/${target.path}`
: "/"}
</span>
</td>
<td className="py-2.5 px-4 align-middle">
<ArrowRight size={12} className="text-nb-gray-400" />
</td>
<td className="py-2.5 pr-2 align-middle">
<TargetDestination target={target} />
</td>
<td className="py-2.5 pl-2 pr-4">
<div
className="flex items-center gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
<ToggleSwitch
size="small"
checked={target.enabled}
onCheckedChange={() => onToggleTargetEnabled(index)}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="default-outline"
className="!px-3"
>
<MoreVertical size={16} className="shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-auto min-w-[200px]"
align="end"
>
<DropdownMenuItem
onClick={() => onEditTarget(index)}
>
<div className="flex gap-3 items-center">
<Edit size={14} className="shrink-0" />
Edit Target
</div>
</DropdownMenuItem>
<DropdownMenuItem
variant={"danger"}
onClick={() => onRemoveTarget(index)}
>
<div className="flex gap-3 items-center">
<MinusCircleIcon
size={14}
className="shrink-0"
/>
Remove Target
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Button
variant="dotted"
className={cn("w-full mt-1", targets?.length > 0 && "mt-1")}
size="sm"
onClick={onAddTarget}
disabled={!!(initialNetwork && !initialNetwork.resources?.length)}
>
<PlusIcon size={14} />
Add Target
</Button>
{initialNetwork && !initialNetwork.resources?.length && (
<Callout
variant="warning"
className="mt-3"
icon={
<AlertTriangle
size={14}
className="shrink-0 relative top-[3px]"
/>
}
>
There are currently no resources in your network{" "}
<span className={"text-netbird-100 font-medium"}>
{initialNetwork?.name}
</span>
. Add resources to your network before exposing it as a service.{" "}
<InlineButtonLink variant={"default"} onClick={onNavigateToResources}>
Go to Resources
<ArrowUpRight size={14} />
</InlineButtonLink>
</Callout>
)}
</div>
);
}
function TargetDestination({ target }: { target: ReverseProxyTarget }) {
const { resolveDestination } = useReverseProxies();
return (
<span className="text-[0.76rem] text-nb-gray-200 whitespace-nowrap font-mono">
{resolveDestination(target)}
</span>
);
}
@@ -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<React.SetStateAction<Target | undefined>>;
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<Props>) {
const listenPortRef = useRef<HTMLInputElement>(null);
const portRef = useRef<HTMLInputElement>(null);
return (
<div className={"-mt-1 flex flex-col gap-8"}>
{!initialResource && !initialPeer && (
<ReverseProxyTargetSelector
value={l4Target}
initialNetwork={initialNetwork}
onChange={(selection) => {
setL4Target(selection);
if (selection) {
setTimeout(() => {
if (isListenPortSupported) {
listenPortRef.current?.focus();
} else {
portRef.current?.focus();
}
}, 0);
}
}}
/>
)}
<div className={"flex gap-4 items-center"}>
<div className={"w-full max-w-[180px]"}>
<Label>
Listen Port
<HelpTooltip
className={"max-w-sm"}
content={
isListenPortSupported
? "Enter the public listen port this service will be reachable on."
: "The listen port will be automatically assigned after the service is created."
}
/>
</Label>
<div className={"mt-2"}>
<Input
ref={listenPortRef}
type="number"
min={1}
max={65535}
placeholder={!isListenPortSupported ? "Auto" : "443"}
value={!isListenPortSupported ? "" : listenPort || ""}
onChange={(e) => setListenPort(parseInt(e.target.value) || 0)}
disabled={!isListenPortSupported || !l4Target}
aria-label="Public listen port"
/>
</div>
</div>
<ArrowRight size={16} className="text-nb-gray-400 shrink-0 mt-6" />
<div className={"w-full flex"}>
<div className={"w-full"}>
<Label>
Host / IP
<CidrHelpText target={l4Target} />
</Label>
<div className="flex w-full mt-2 relative">
<ReverseProxyAddressInput
value={l4Target}
onChange={setL4Target}
/>
</div>
</div>
<div>
<Label>
Port
<HelpTooltip
content={
"Enter the port where your service (e.g., webserver, app, API) is currently listening."
}
/>
</Label>
<div className={"mt-2 min-w-[120px]"}>
<Input
ref={portRef}
type="number"
min={1}
max={65535}
placeholder="443"
value={port || ""}
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
disabled={!l4Target}
aria-label="Destination port"
className={"rounded-l-none"}
/>
</div>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,125 @@
import React, { ReactNode, useEffect } from "react";
import {
isL4Mode as isL4ServiceMode,
type ReverseProxyDomain,
ServiceMode,
} from "@/interfaces/ReverseProxy";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { ArrowRightFromLine, Globe, LockKeyhole } from "lucide-react";
import { HelpTooltip } from "@components/HelpTooltip";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
type Props = {
value?: ServiceMode;
onChange: (value: ServiceMode) => void;
disabled?: boolean;
domain?: ReverseProxyDomain;
};
type ServiceModeConfig = {
label: string;
description: string;
icon: ReactNode;
};
export const SERVICE_MODES: Record<ServiceMode, ServiceModeConfig> = {
[ServiceMode.HTTP]: {
label: "HTTP/S Service",
description:
"Reverse proxy with path routing and built-in authentication (SSO, PIN, password). Typically used for web applications and APIs.",
icon: <Globe size={14} />,
},
[ServiceMode.TLS]: {
label: "TLS Passthrough",
description:
"Passes encrypted TLS traffic straight through to the backend. Typically used for services that manage their own TLS certificates.",
icon: <LockKeyhole size={14} />,
},
[ServiceMode.TCP]: {
label: "TCP Service",
description:
"Forwards raw TCP traffic to your backend on a dedicated port. Typically used for databases, custom protocols, or any TCP-based service.",
icon: <ArrowRightFromLine size={14} />,
},
[ServiceMode.UDP]: {
label: "UDP Service",
description:
"Forwards raw UDP traffic to your backend on a dedicated port. Typically used for real-time services like voice, video, or streaming.",
icon: <ArrowRightFromLine size={14} />,
},
};
export const ReverseProxyServiceModeSelector = ({
value,
onChange,
disabled,
domain,
}: Props) => {
const selected = value ?? ServiceMode.HTTP;
const selectedMode = SERVICE_MODES[selected];
const isL4Supported = domain?.supports_custom_ports === true;
// Reset to HTTP if the current L4 mode becomes unsupported (e.g. domain changed)
useEffect(() => {
if (!isL4Supported && isL4ServiceMode(selected)) {
onChange(ServiceMode.HTTP);
}
}, [isL4Supported, selected, onChange]);
return (
<div className="flex justify-between items-center gap-10 mt-2">
<div>
<Label>Service Type</Label>
<HelpText>
Select a type to define how the proxy handles and forwards traffic to
your backend services.
</HelpText>
</div>
<Select
value={selected}
onValueChange={(v) => onChange(v as ServiceMode)}
disabled={disabled}
>
<SelectTrigger className="max-w-[240px] min-w-[200px]">
<div
className={"flex items-center gap-2 whitespace-nowrap"}
data-cy={"service-mode-select-button"}
>
{selectedMode.icon}
<SelectValue placeholder="Select type..." />
</div>
</SelectTrigger>
<SelectContent data-cy={"service-mode-selection"}>
{Object.entries(SERVICE_MODES)
.filter(
([mode]) =>
isL4Supported || !isL4ServiceMode(mode as ServiceMode),
)
.map(([mode, config]) => (
<SelectItem
key={mode}
value={mode}
extra={
<HelpTooltip
triggerClassName={"ml-[0.01rem]"}
align={"center"}
side={"right"}
content={<>{config.description}</>}
/>
}
>
<span className="whitespace-nowrap">{config.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -23,6 +23,7 @@ import {
} from "@/interfaces/ReverseProxy";
import HelpText from "@components/HelpText";
import Separator from "@components/Separator";
import { isNetBirdHosted } from "@/utils/netbird";
import {
SelectDropdown,
SelectOption,
@@ -99,18 +100,35 @@ export const CustomDomainModal = ({
<div className={"px-8 flex flex-col gap-6 pt-6 pb-8"}>
{availableClusters.length === 0 ? (
<Callout variant="warning">
No proxy clusters are currently connected. Please ensure at least
one proxy is running before adding a domain. <br /> Learn more
about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
isNetBirdHosted() ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please try again in a
few minutes. If the issue persists, check{" "}
<InlineLink
href={"https://status.netbird.io/"}
target={"_blank"}
>
NetBird Status
</InlineLink>{" "}
or reach out to{" "}
<InlineLink href={"mailto:support@netbird.io"}>
support@netbird.io
</InlineLink>
</Callout>
) : (
<Callout variant="warning">
No proxy clusters are currently connected. Please ensure at
least one proxy is running before adding a domain. <br /> Learn
more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
)
) : (
<>
<div>
@@ -22,6 +22,7 @@ import {
} from "@/interfaces/ReverseProxy";
import Paragraph from "@components/Paragraph";
import InlineLink from "@components/InlineLink";
import { isNetBirdHosted } from "@/utils/netbird";
type Props = {
open: boolean;
@@ -79,18 +80,36 @@ export const CustomDomainVerificationModal = ({
</Steps>
<div className={"flex flex-col gap-6"}>
{!cnameTarget ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please ensure at
least one proxy is running to configure DNS verification. <br />
Learn more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
isNetBirdHosted() ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please try again in
a few minutes. If the issue persists, check{" "}
<InlineLink
href={"https://status.netbird.io/"}
target={"_blank"}
>
NetBird Status
</InlineLink>{" "}
or reach out to{" "}
<InlineLink href={"mailto:support@netbird.io"}>
support@netbird.io
</InlineLink>
</Callout>
) : (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please ensure at
least one proxy is running to configure DNS verification.{" "}
<br />
Learn more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
)
) : (
<>
<Card className={"w-full"}>
@@ -0,0 +1,84 @@
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { Callout } from "@components/Callout";
import React from "react";
import { CustomDomainSelector } from "./CustomDomainSelector";
import { isNetBirdHosted } from "@utils/netbird";
import InlineLink from "@components/InlineLink";
type Props = {
subdomain: string;
onSubdomainChange: (value: string) => void;
baseDomain: string;
onBaseDomainChange: (value: string) => void;
domainAlreadyExists: boolean;
clusterOffline?: {
clusterName: string;
};
};
export default function ReverseProxyDomainInput({
subdomain,
onSubdomainChange,
baseDomain,
onBaseDomainChange,
domainAlreadyExists,
clusterOffline,
}: Readonly<Props>) {
return (
<div>
<Label>Domain</Label>
<HelpText>
Enter a subdomain and select a domain for your service.
</HelpText>
<div className="flex items-start mt-2">
<div className="flex-1 min-w-0">
<Input
autoFocus
value={subdomain}
onChange={(e) => {
onSubdomainChange(
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"
/>
</div>
<div className="flex-1 min-w-0">
<CustomDomainSelector
value={baseDomain}
onChange={onBaseDomainChange}
className="!rounded-l-none"
/>
</div>
</div>
{clusterOffline &&
(isNetBirdHosted() ? (
<Callout variant={"warning"} className={"mt-3"}>
Cluster {clusterOffline.clusterName} is offline. Please try again in
a few minutes. If the issue persists, check{" "}
<InlineLink href={"https://status.netbird.io/"} target={"_blank"}>
NetBird Status
</InlineLink>{" "}
or reach out to{" "}
<InlineLink href={"mailto:support@netbird.io"}>
support@netbird.io
</InlineLink>
</Callout>
) : (
<Callout variant={"error"} className={"mt-3"}>
Cluster {clusterOffline.clusterName} is offline. Make sure the proxy
server is running and connected to the right management address.
</Callout>
))}
</div>
);
}
@@ -0,0 +1,133 @@
import { useMemo, useState } from "react";
import {
ReverseProxy,
ReverseProxyDomain,
ReverseProxyDomainType,
} from "@/interfaces/ReverseProxy";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
// Helper to parse domain into subdomain and base domain.
// When availableDomains is provided, matches against them first (longest match wins)
// to avoid e.g. "netbird.io" matching when the actual domain is "eu.proxy.netbird.io".
function parseDomain(
fullDomain: string,
availableDomains?: ReverseProxyDomain[],
): {
subdomain: string;
baseDomain: string;
isCustom: boolean;
} {
// Try matching against actual available domains first (sorted longest-first for specificity)
if (availableDomains?.length) {
const sorted = [...availableDomains]
.filter((d) => d.domain)
.sort((a, b) => b.domain.length - a.domain.length);
for (const d of sorted) {
if (fullDomain.endsWith(`.${d.domain}`)) {
return {
subdomain: fullDomain.slice(0, -(d.domain.length + 1)),
baseDomain: d.domain,
isCustom: d.type === ReverseProxyDomainType.CUSTOM,
};
}
}
}
// Fallback to hardcoded known domains
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,
};
}
type UseReverseProxyDomainOptions = {
reverseProxy?: ReverseProxy;
domains?: ReverseProxyDomain[];
initialSubdomain?: string;
};
export function useReverseProxyDomain({
reverseProxy,
domains,
initialSubdomain,
}: UseReverseProxyDomainOptions) {
const { reverseProxies } = useReverseProxies();
const parsed = reverseProxy?.domain
? parseDomain(reverseProxy.domain, domains)
: null;
const [subdomain, setSubdomain] = useState(() => {
return (
parsed?.subdomain ||
initialSubdomain
?.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "") ||
""
);
});
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 fullDomain = baseDomain ? `${subdomain}.${baseDomain}` : subdomain;
const domainAlreadyExists = useMemo(() => {
if (!reverseProxies || !fullDomain) return false;
return reverseProxies.some(
(p) => p.domain === fullDomain && p.id !== reverseProxy?.id,
);
}, [reverseProxies, fullDomain, reverseProxy?.id]);
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]);
return {
subdomain,
setSubdomain,
baseDomain,
setBaseDomain,
fullDomain,
domainAlreadyExists,
isClusterConnected,
};
}
@@ -0,0 +1,32 @@
import { cn, formatBytes } from "@utils/helpers";
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
event: ReverseProxyEvent;
};
export const ReverseProxyEventsBytesCell = ({ event }: Props) => {
if (
(event.bytes_download === undefined || event.bytes_download === 0) &&
(event.bytes_upload === undefined || event.bytes_upload === 0)
)
return <EmptyRow />;
return (
<div className={"flex flex-col text-xs gap-1 text-nb-gray-300 font-medium"}>
<div className={"flex gap-2 items-center whitespace-nowrap"}>
<ArrowDownIcon size={15} className={cn("text-sky-400")} />
<span className="sr-only">Download:</span>
{formatBytes(event.bytes_download ?? 0)}
</div>
<div className={"flex gap-2 items-center whitespace-nowrap"}>
<ArrowUpIcon size={15} className={cn("text-netbird")} />
<span className="sr-only">Upload:</span>
{formatBytes(event.bytes_upload ?? 0)}
</div>
</div>
);
};
Loaded 20 of 41 files, more files were not shown because too many files have changed in this diff. Show more