Add layer 4 protocol support to reverse proxy (#579)
Some checks failed
build and push / build_n_push (push) Has been cancelled
Some checks failed
build and push / build_n_push (push) Has been cancelled
* 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>
This commit is contained in:
88
package-lock.json
generated
88
package-lock.json
generated
@@ -60,7 +60,7 @@
|
|||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.7",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"punycode": "^2.3.1",
|
"punycode": "^2.3.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -1213,9 +1213,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
|
||||||
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
|
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@@ -1229,9 +1229,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
|
||||||
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
|
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1245,9 +1245,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
|
||||||
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
|
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1261,9 +1261,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
|
||||||
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
|
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1277,9 +1277,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
|
||||||
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
|
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1293,9 +1293,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
|
||||||
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
|
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1309,9 +1309,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
|
||||||
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
|
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1325,9 +1325,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
|
||||||
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
|
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1341,9 +1341,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
|
||||||
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
|
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -5768,9 +5768,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.3",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
@@ -7067,14 +7067,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.1.6",
|
"version": "16.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
|
||||||
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
|
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.1.6",
|
"@next/env": "16.1.7",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"baseline-browser-mapping": "^2.8.3",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
"styled-jsx": "5.1.6"
|
"styled-jsx": "5.1.6"
|
||||||
@@ -7086,14 +7086,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.1.6",
|
"@next/swc-darwin-arm64": "16.1.7",
|
||||||
"@next/swc-darwin-x64": "16.1.6",
|
"@next/swc-darwin-x64": "16.1.7",
|
||||||
"@next/swc-linux-arm64-gnu": "16.1.6",
|
"@next/swc-linux-arm64-gnu": "16.1.7",
|
||||||
"@next/swc-linux-arm64-musl": "16.1.6",
|
"@next/swc-linux-arm64-musl": "16.1.7",
|
||||||
"@next/swc-linux-x64-gnu": "16.1.6",
|
"@next/swc-linux-x64-gnu": "16.1.7",
|
||||||
"@next/swc-linux-x64-musl": "16.1.6",
|
"@next/swc-linux-x64-musl": "16.1.7",
|
||||||
"@next/swc-win32-arm64-msvc": "16.1.6",
|
"@next/swc-win32-arm64-msvc": "16.1.7",
|
||||||
"@next/swc-win32-x64-msvc": "16.1.6",
|
"@next/swc-win32-x64-msvc": "16.1.7",
|
||||||
"sharp": "^0.34.4"
|
"sharp": "^0.34.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.7",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"punycode": "^2.3.1",
|
"punycode": "^2.3.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ export default function ReverseProxyIcon(props: IconProps) {
|
|||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
{...iconProperties(props)}
|
{...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>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,13 +80,15 @@ export const DeviceCard = ({
|
|||||||
hideTooltip={true}
|
hideTooltip={true}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span
|
{descriptionText && (
|
||||||
className={
|
<span
|
||||||
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
|
className={
|
||||||
}
|
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
|
||||||
>
|
}
|
||||||
<TruncatedText text={descriptionText} maxWidth={"160px"} />
|
>
|
||||||
</span>
|
<TruncatedText text={descriptionText} maxWidth={"160px"} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Props = {
|
|||||||
description: ReactNode;
|
description: ReactNode;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RadioCard = ({
|
export const RadioCard = ({
|
||||||
@@ -16,15 +17,18 @@ export const RadioCard = ({
|
|||||||
description,
|
description,
|
||||||
className,
|
className,
|
||||||
icon,
|
icon,
|
||||||
|
disabled,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<RadioGroup.Item
|
<RadioGroup.Item
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
className={cn(
|
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",
|
"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",
|
"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",
|
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
|
||||||
"hover:bg-nb-gray-930",
|
"hover:bg-nb-gray-930",
|
||||||
|
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -77,23 +77,56 @@ const SelectItem = React.forwardRef<
|
|||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||||
extra?: React.ReactNode;
|
extra?: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
}
|
}
|
||||||
>(({ className, children, extra, ...props }, ref) => (
|
>(({ className, children, extra, icon, description, ...props }, ref) => (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
{icon ? (
|
||||||
<SelectPrimitive.ItemIndicator>
|
<>
|
||||||
<Check className="h-4 w-4" />
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
</SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
</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}
|
{extra}
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Skeleton from "react-loading-skeleton";
|
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 (
|
return (
|
||||||
<div className={"min-h-[59px] relative -left-2"}>
|
<div
|
||||||
<div className={"py-2 pr-4 pl-2 flex gap-3"}>
|
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={36} width={36} />
|
||||||
<Skeleton height={16} width={70} />
|
<div className={"flex flex-col pr-[1.15rem]"}>
|
||||||
<Skeleton height={16} width={140} />
|
<Skeleton height={16} width={70} />
|
||||||
</div>
|
<Skeleton height={16} width={140} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
|
|||||||
|
|
||||||
type ReverseProxiesContextValue = {
|
type ReverseProxiesContextValue = {
|
||||||
reverseProxies: ReverseProxy[] | undefined;
|
reverseProxies: ReverseProxy[] | undefined;
|
||||||
|
resources: NetworkResource[] | undefined;
|
||||||
|
peers: Peer[] | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
openModal: (options?: OpenModalOptions) => void;
|
openModal: (options?: OpenModalOptions) => void;
|
||||||
openTargetModal: (options: OpenTargetModalOptions) => void;
|
openTargetModal: (options: OpenTargetModalOptions) => void;
|
||||||
@@ -93,7 +95,7 @@ export default function ReverseProxiesProvider({
|
|||||||
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
|
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
|
||||||
"/reverse-proxies/services",
|
"/reverse-proxies/services",
|
||||||
);
|
);
|
||||||
const request = useApiCall<ReverseProxy>("/reverse-proxies/services");
|
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
|
||||||
|
|
||||||
// Peers & Resources for resolving target destinations
|
// Peers & Resources for resolving target destinations
|
||||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||||
@@ -465,6 +467,8 @@ export default function ReverseProxiesProvider({
|
|||||||
<ReverseProxiesContext.Provider
|
<ReverseProxiesContext.Provider
|
||||||
value={{
|
value={{
|
||||||
reverseProxies,
|
reverseProxies,
|
||||||
|
resources,
|
||||||
|
peers,
|
||||||
isLoading,
|
isLoading,
|
||||||
openModal,
|
openModal,
|
||||||
openTargetModal,
|
openTargetModal,
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
|
export enum ServiceMode {
|
||||||
|
HTTP = "http",
|
||||||
|
TCP = "tcp",
|
||||||
|
UDP = "udp",
|
||||||
|
TLS = "tls",
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReverseProxy {
|
export interface ReverseProxy {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
mode?: ServiceMode;
|
||||||
|
listen_port?: number;
|
||||||
|
port_auto_assigned?: boolean;
|
||||||
proxy_cluster?: string;
|
proxy_cluster?: string;
|
||||||
targets: ReverseProxyTarget[];
|
targets: ReverseProxyTarget[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -31,8 +41,10 @@ export type ServiceTargetOptionsPathRewrite = "preserve";
|
|||||||
export interface ServiceTargetOptions {
|
export interface ServiceTargetOptions {
|
||||||
skip_tls_verify?: boolean;
|
skip_tls_verify?: boolean;
|
||||||
request_timeout?: string;
|
request_timeout?: string;
|
||||||
|
session_idle_timeout?: string;
|
||||||
path_rewrite?: ServiceTargetOptionsPathRewrite;
|
path_rewrite?: ServiceTargetOptionsPathRewrite;
|
||||||
custom_headers?: Record<string, string>;
|
custom_headers?: Record<string, string>;
|
||||||
|
proxy_protocol?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReverseProxyTarget {
|
export interface ReverseProxyTarget {
|
||||||
@@ -73,6 +85,7 @@ export interface ReverseProxyDomain {
|
|||||||
validated: boolean;
|
validated: boolean;
|
||||||
type: ReverseProxyDomainType;
|
type: ReverseProxyDomainType;
|
||||||
target_cluster?: string;
|
target_cluster?: string;
|
||||||
|
supports_custom_ports?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ReverseProxyDomainType {
|
export enum ReverseProxyDomainType {
|
||||||
@@ -90,6 +103,15 @@ export enum ReverseProxyTargetType {
|
|||||||
export enum ReverseProxyTargetProtocol {
|
export enum ReverseProxyTargetProtocol {
|
||||||
HTTP = "http",
|
HTTP = "http",
|
||||||
HTTPS = "https",
|
HTTPS = "https",
|
||||||
|
TCP = "tcp",
|
||||||
|
UDP = "udp",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EventProtocol {
|
||||||
|
HTTP = "http",
|
||||||
|
TCP = "tcp",
|
||||||
|
UDP = "udp",
|
||||||
|
TLS = "tls",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReverseProxyEvent {
|
export interface ReverseProxyEvent {
|
||||||
@@ -107,12 +129,31 @@ export interface ReverseProxyEvent {
|
|||||||
auth_method_used?: string;
|
auth_method_used?: string;
|
||||||
country_code?: string;
|
country_code?: string;
|
||||||
city_name?: 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 {
|
export interface ReverseProxyFlatTarget extends ReverseProxyTarget {
|
||||||
proxy: ReverseProxy;
|
proxy: ReverseProxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isL4Mode(mode?: ServiceMode): boolean {
|
||||||
|
return (
|
||||||
|
mode === ServiceMode.TCP ||
|
||||||
|
mode === ServiceMode.UDP ||
|
||||||
|
mode === ServiceMode.TLS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const REVERSE_PROXY_DOCS_LINK =
|
export const REVERSE_PROXY_DOCS_LINK =
|
||||||
"https://docs.netbird.io/manage/reverse-proxy";
|
"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 =
|
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
|
||||||
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
|
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
|
||||||
|
|
||||||
|
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
|
||||||
|
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";
|
||||||
|
|||||||
182
src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx
Normal file
182
src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx
Normal file
133
src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@components/DropdownMenu";
|
|
||||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||||
import HelpText from "@components/HelpText";
|
import HelpText from "@components/HelpText";
|
||||||
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import { Input } from "@components/Input";
|
import { Input } from "@components/Input";
|
||||||
import { Label } from "@components/Label";
|
import { Label } from "@components/Label";
|
||||||
import SettingCard from "@components/SettingCard";
|
import SettingCard from "@components/SettingCard";
|
||||||
@@ -22,27 +16,19 @@ import {
|
|||||||
import Paragraph from "@components/Paragraph";
|
import Paragraph from "@components/Paragraph";
|
||||||
import ModalHeader from "@components/modal/ModalHeader";
|
import ModalHeader from "@components/modal/ModalHeader";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpRight,
|
|
||||||
Binary,
|
Binary,
|
||||||
Edit,
|
ClockFadingIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
MinusCircleIcon,
|
MapPinned,
|
||||||
MoreVertical,
|
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
PlusIcon,
|
|
||||||
RectangleEllipsis,
|
RectangleEllipsis,
|
||||||
Server,
|
|
||||||
Settings,
|
Settings,
|
||||||
Text,
|
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Callout } from "@components/Callout";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||||
@@ -51,23 +37,38 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
|||||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||||
import { Peer } from "@/interfaces/Peer";
|
import { Peer } from "@/interfaces/Peer";
|
||||||
import {
|
import {
|
||||||
|
isL4Mode as isL4ServiceMode,
|
||||||
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
|
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
|
||||||
REVERSE_PROXY_SERVICES_DOCS_LINK,
|
REVERSE_PROXY_SERVICES_DOCS_LINK,
|
||||||
REVERSE_PROXY_SETTINGS_DOCS_LINK,
|
REVERSE_PROXY_SETTINGS_DOCS_LINK,
|
||||||
ReverseProxy,
|
ReverseProxy,
|
||||||
ReverseProxyAuth,
|
ReverseProxyAuth,
|
||||||
ReverseProxyDomain,
|
ReverseProxyDomain,
|
||||||
ReverseProxyDomainType,
|
|
||||||
ReverseProxyTarget,
|
ReverseProxyTarget,
|
||||||
|
ReverseProxyTargetProtocol,
|
||||||
|
ReverseProxyTargetType,
|
||||||
|
ServiceMode,
|
||||||
} from "@/interfaces/ReverseProxy";
|
} from "@/interfaces/ReverseProxy";
|
||||||
import { CustomDomainSelector } from "./domain/CustomDomainSelector";
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
import { cn } from "@utils/helpers";
|
import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput";
|
||||||
|
import { useReverseProxyDomain } from "./domain/useReverseProxyDomain";
|
||||||
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
|
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
|
||||||
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
|
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
|
||||||
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
|
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 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 useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
import {
|
||||||
|
ReverseProxyServiceModeSelector,
|
||||||
|
SERVICE_MODES,
|
||||||
|
} from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -84,41 +85,6 @@ type Props = {
|
|||||||
onSuccess?: () => void;
|
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({
|
export default function ReverseProxyModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -134,51 +100,112 @@ export default function ReverseProxyModal({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
const { confirm } = useDialog();
|
const { confirm } = useDialog();
|
||||||
const { reverseProxies, handleCreateOrUpdateProxy } = useReverseProxies();
|
const { handleCreateOrUpdateProxy } = useReverseProxies();
|
||||||
|
|
||||||
// Check if the proxy's cluster exists in available free domains
|
const {
|
||||||
const isClusterConnected = useMemo(() => {
|
subdomain,
|
||||||
if (!reverseProxy?.proxy_cluster) return false;
|
setSubdomain,
|
||||||
return domains?.some(
|
baseDomain,
|
||||||
(d) =>
|
setBaseDomain,
|
||||||
d.type === ReverseProxyDomainType.FREE &&
|
fullDomain,
|
||||||
d.domain === reverseProxy.proxy_cluster,
|
domainAlreadyExists,
|
||||||
);
|
isClusterConnected,
|
||||||
}, [reverseProxy?.proxy_cluster, domains]);
|
} = useReverseProxyDomain({ reverseProxy, domains, initialSubdomain });
|
||||||
|
|
||||||
const [tab, setTab] = useState(() => {
|
const [tab, setTab] = useState(() => {
|
||||||
if (initialTab && initialTab !== "") return initialTab;
|
if (initialTab && initialTab !== "") return initialTab;
|
||||||
return "targets";
|
return "targets";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse existing domain if editing
|
const [serviceMode, setServiceMode] = useState<ServiceMode>(
|
||||||
const parsed = reverseProxy?.domain ? parseDomain(reverseProxy.domain) : null;
|
reverseProxy?.mode ?? ServiceMode.HTTP,
|
||||||
|
);
|
||||||
|
|
||||||
// Form state
|
const isL4Mode = isL4ServiceMode(serviceMode);
|
||||||
const [subdomain, setSubdomain] = useState(
|
|
||||||
parsed?.subdomain ||
|
// L4 target selection state (TLS/TCP/UDP) - target is in targets[0]
|
||||||
initialSubdomain
|
const [l4Target, setL4Target] = useState<Target | undefined>(() => {
|
||||||
?.toLowerCase()
|
const existing = isL4ServiceMode(reverseProxy?.mode)
|
||||||
.replace(/\s+/g, "-")
|
? reverseProxy?.targets?.[0]
|
||||||
.replace(/[^a-z0-9-]/g, "") ||
|
: 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<number>(
|
||||||
|
reverseProxy?.targets?.[0]?.port || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [listenPort, setListenPort] = useState<number>(
|
||||||
|
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(() => {
|
const timeoutError = useMemo(() => {
|
||||||
if (parsed?.baseDomain) return parsed.baseDomain;
|
if (!timeoutOption) return undefined;
|
||||||
const validatedDomains = domains?.filter((d) => d.validated) || [];
|
return serviceMode === ServiceMode.UDP
|
||||||
const customDomain = validatedDomains.find(
|
? validateSessionIdleTimeout(timeoutOption)
|
||||||
(d) => d.type === ReverseProxyDomainType.CUSTOM,
|
: validateTimeout(timeoutOption);
|
||||||
);
|
}, [timeoutOption, serviceMode]);
|
||||||
const freeDomain = validatedDomains.find(
|
|
||||||
(d) => d.type === ReverseProxyDomainType.FREE,
|
|
||||||
);
|
|
||||||
return customDomain?.domain || freeDomain?.domain || "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const [targets, setTargets] = useState<ReverseProxyTarget[]>(
|
const [targets, setTargets] = useState<ReverseProxyTarget[]>(
|
||||||
reverseProxy?.targets || [],
|
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(
|
const [passHostHeader, setPassHostHeader] = useState(
|
||||||
reverseProxy?.pass_host_header ?? false,
|
reverseProxy?.pass_host_header ?? false,
|
||||||
);
|
);
|
||||||
@@ -186,19 +213,6 @@ export default function ReverseProxyModal({
|
|||||||
reverseProxy?.rewrite_redirects ?? false,
|
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
|
// Authentication options - initialized from existing reverseProxy.auth
|
||||||
const [passwordEnabled, setPasswordEnabled] = useState(
|
const [passwordEnabled, setPasswordEnabled] = useState(
|
||||||
reverseProxy?.auth?.password_auth?.enabled ?? false,
|
reverseProxy?.auth?.password_auth?.enabled ?? false,
|
||||||
@@ -233,19 +247,32 @@ export default function ReverseProxyModal({
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSubdomainValid = useMemo(() => {
|
|
||||||
return (
|
|
||||||
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists
|
|
||||||
);
|
|
||||||
}, [subdomain, baseDomain, domainAlreadyExists]);
|
|
||||||
|
|
||||||
const canContinueToSettings = useMemo(() => {
|
const canContinueToSettings = useMemo(() => {
|
||||||
return isSubdomainValid && targets.length > 0;
|
const isSubdomainValid =
|
||||||
}, [isSubdomainValid, targets]);
|
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists;
|
||||||
|
const isValidPort = (port: number) => port >= 1 && port <= 65535;
|
||||||
const submitDisabled = useMemo(() => {
|
const hasHttpEndpoint = !isL4Mode && targets.length > 0;
|
||||||
return !canContinueToSettings;
|
const hasL4Endpoint =
|
||||||
}, [canContinueToSettings]);
|
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) => {
|
const saveTarget = (targetData: ReverseProxyTarget) => {
|
||||||
if (editingTargetIndex !== null) {
|
if (editingTargetIndex !== null) {
|
||||||
@@ -282,8 +309,8 @@ export default function ReverseProxyModal({
|
|||||||
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
|
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
// Show warning if no authentication is configured
|
// Show warning if no authentication is configured (HTTP only; TLS is pass-through)
|
||||||
if (hasNoAuth) {
|
if (!isL4Mode && hasNoAuth) {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: "No Authentication Configured",
|
title: "No Authentication Configured",
|
||||||
description:
|
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<string, unknown> = {};
|
||||||
|
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({
|
handleCreateOrUpdateProxy({
|
||||||
data: {
|
data: {
|
||||||
name: fullDomain,
|
name: fullDomain,
|
||||||
domain: 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,
|
enabled: reverseProxy?.enabled ?? true,
|
||||||
pass_host_header: passHostHeader,
|
pass_host_header: isL4Mode ? undefined : passHostHeader,
|
||||||
rewrite_redirects: rewriteRedirects,
|
rewrite_redirects: isL4Mode ? undefined : rewriteRedirects,
|
||||||
auth,
|
auth: isL4Mode ? undefined : auth,
|
||||||
},
|
},
|
||||||
proxyId: reverseProxy?.id,
|
proxyId: reverseProxy?.id,
|
||||||
onSuccess: () => {
|
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 (
|
return (
|
||||||
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
|
<Modal open={open} onOpenChange={onOpenChange} key={open ? 1 : 0}>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
@@ -341,23 +413,23 @@ export default function ReverseProxyModal({
|
|||||||
>
|
>
|
||||||
<ModalHeader
|
<ModalHeader
|
||||||
icon={<ReverseProxyIcon className={"fill-netbird"} size={18} />}
|
icon={<ReverseProxyIcon className={"fill-netbird"} size={18} />}
|
||||||
title={reverseProxy ? "Edit Service" : "Add Service"}
|
title={modalTitle}
|
||||||
description={
|
description={modalDescription}
|
||||||
"Expose services securely through NetBird's reverse proxy."
|
|
||||||
}
|
|
||||||
color={"netbird"}
|
color={"netbird"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
<TabsList justify={"start"} className={"px-8"}>
|
<TabsList justify={"start"} className={"px-8"}>
|
||||||
<TabsTrigger value={"targets"}>
|
<TabsTrigger value={"targets"}>
|
||||||
<Text size={14} />
|
<ReverseProxyIcon size={14} />
|
||||||
Details
|
Service
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
|
|
||||||
<LockKeyhole size={16} />
|
|
||||||
Authentication
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{!isL4Mode && (
|
||||||
|
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
|
||||||
|
<LockKeyhole size={16} />
|
||||||
|
Authentication
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
|
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
|
||||||
<Settings size={14} />
|
<Settings size={14} />
|
||||||
Advanced Settings
|
Advanced Settings
|
||||||
@@ -366,199 +438,56 @@ export default function ReverseProxyModal({
|
|||||||
|
|
||||||
<TabsContent value={"targets"} className={"pb-8"}>
|
<TabsContent value={"targets"} className={"pb-8"}>
|
||||||
<div className={"px-8 flex-col flex gap-6"}>
|
<div className={"px-8 flex-col flex gap-6"}>
|
||||||
<div>
|
<ReverseProxyDomainInput
|
||||||
<Label>
|
subdomain={subdomain}
|
||||||
<GlobeIcon size={14} />
|
onSubdomainChange={setSubdomain}
|
||||||
Domain
|
baseDomain={baseDomain}
|
||||||
</Label>
|
onBaseDomainChange={setBaseDomain}
|
||||||
<HelpText>
|
domainAlreadyExists={domainAlreadyExists}
|
||||||
Enter a subdomain and select a domain for your service.
|
clusterOffline={
|
||||||
</HelpText>
|
reverseProxy?.proxy_cluster && !isClusterConnected
|
||||||
<div className="flex items-start mt-2">
|
? { clusterName: reverseProxy.proxy_cluster }
|
||||||
<div className="flex-1 min-w-0">
|
: undefined
|
||||||
<Input
|
}
|
||||||
autoFocus
|
/>
|
||||||
value={subdomain}
|
|
||||||
onChange={(e) => {
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CustomDomainSelector
|
|
||||||
value={baseDomain}
|
|
||||||
onChange={setBaseDomain}
|
|
||||||
className="!rounded-l-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reverseProxy?.proxy_cluster && !isClusterConnected && (
|
{!reverseProxy && (
|
||||||
<Callout variant={"error"}>
|
<ReverseProxyServiceModeSelector
|
||||||
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the
|
onChange={setServiceMode}
|
||||||
proxy server is running and connected to the right management
|
value={serviceMode}
|
||||||
address.
|
domain={selectedDomain}
|
||||||
</Callout>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
{isL4Mode ? (
|
||||||
<Label>
|
<ReverseProxyLayer4Content
|
||||||
<Server size={14} />
|
l4Target={l4Target}
|
||||||
Targets
|
setL4Target={setL4Target}
|
||||||
</Label>
|
isListenPortSupported={isListenPortSupported}
|
||||||
<HelpText>
|
listenPort={listenPort}
|
||||||
Add one or more devices running your service or resources to
|
setListenPort={setListenPort}
|
||||||
make it publicly accessible.
|
port={port}
|
||||||
</HelpText>
|
setPort={setPort}
|
||||||
|
initialResource={initialResource}
|
||||||
{targets.length > 0 && (
|
initialPeer={initialPeer}
|
||||||
<div
|
initialNetwork={initialNetwork}
|
||||||
className={
|
/>
|
||||||
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md "
|
) : (
|
||||||
}
|
<ReverseProxyHTTPTargets
|
||||||
>
|
targets={targets}
|
||||||
<table className="w-full">
|
onEditTarget={editTarget}
|
||||||
<tbody>
|
onRemoveTarget={removeTarget}
|
||||||
{targets.map((target, index) => (
|
onToggleTargetEnabled={toggleTargetEnabled}
|
||||||
<tr
|
onAddTarget={() => setTargetModalOpen(true)}
|
||||||
key={index}
|
initialNetwork={initialNetwork}
|
||||||
onClick={() => editTarget(index)}
|
onNavigateToResources={() => {
|
||||||
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
|
onOpenChange(false);
|
||||||
>
|
router.push(
|
||||||
<td className="py-2.5 pl-5 pr-2 align-middle">
|
`/network?id=${initialNetwork?.id}&tab=resources`,
|
||||||
<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 !== false}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
toggleTargetEnabled(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={() => editTarget(index)}
|
|
||||||
>
|
|
||||||
<div className="flex gap-3 items-center">
|
|
||||||
<Edit size={14} className="shrink-0" />
|
|
||||||
Edit Target
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant={"danger"}
|
|
||||||
onClick={() => removeTarget(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={() => setTargetModalOpen(true)}
|
|
||||||
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={() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
router.push(
|
|
||||||
`/network?id=${initialNetwork.id}&tab=resources`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Go to Resources
|
|
||||||
<ArrowUpRight size={14} />
|
|
||||||
</InlineButtonLink>
|
|
||||||
</Callout>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -603,73 +532,116 @@ export default function ReverseProxyModal({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value={"settings"} className={"pb-8"}>
|
<TabsContent value={"settings"} className={"pb-8"}>
|
||||||
<div className={"px-8 flex-col flex gap-4"}>
|
<div className={"px-8 flex-col flex gap-6"}>
|
||||||
<FancyToggleSwitch
|
{(serviceMode === ServiceMode.TCP ||
|
||||||
value={passHostHeader}
|
serviceMode === ServiceMode.TLS) && (
|
||||||
onChange={setPassHostHeader}
|
<FancyToggleSwitch
|
||||||
label={
|
value={proxyProtocol}
|
||||||
<>
|
onChange={setProxyProtocol}
|
||||||
<GlobeIcon size={15} />
|
label={
|
||||||
Pass Host Header
|
<>
|
||||||
</>
|
<MapPinned size={15} />
|
||||||
}
|
Preserve Client Source IP
|
||||||
helpText="Forward the original Host header to the backend instead of rewriting it to the target address."
|
</>
|
||||||
/>
|
}
|
||||||
<FancyToggleSwitch
|
helpText="Preserve client source IP addresses when forwarding traffic to the backend using PROXY Protocol v2."
|
||||||
value={rewriteRedirects}
|
/>
|
||||||
onChange={setRewriteRedirects}
|
)}
|
||||||
label={
|
|
||||||
<>
|
{isL4Mode && (
|
||||||
<ArrowRight size={15} />
|
<>
|
||||||
Rewrite Redirects
|
<div className={"flex items-center justify-between"}>
|
||||||
</>
|
<div>
|
||||||
}
|
<Label>
|
||||||
helpText="Rewrite Location headers in backend responses to use the public domain instead of the internal backend address."
|
{serviceMode === ServiceMode.UDP
|
||||||
/>
|
? "Session Idle Timeout"
|
||||||
|
: "Connection Timeout"}
|
||||||
|
</Label>
|
||||||
|
<HelpText className={"mb-0"}>
|
||||||
|
{serviceMode === ServiceMode.UDP ? (
|
||||||
|
<>
|
||||||
|
Close the UDP session after this period of
|
||||||
|
inactivity.
|
||||||
|
<br /> Leave this field empty for no timeout.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Timeout for establishing backend connections. <br />{" "}
|
||||||
|
Leave this field empty for no timeout.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
customPrefix={<ClockFadingIcon size={16} />}
|
||||||
|
placeholder="e.g. 10s, 30s, 1m"
|
||||||
|
value={timeoutOption}
|
||||||
|
onChange={(e) => setTimeoutOption(e.target.value)}
|
||||||
|
maxWidthClass="w-[180px]"
|
||||||
|
errorTooltip={true}
|
||||||
|
error={timeoutError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isL4Mode && (
|
||||||
|
<div className={"flex flex-col gap-4"}>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={passHostHeader}
|
||||||
|
onChange={setPassHostHeader}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<GlobeIcon size={15} />
|
||||||
|
Pass Host Header
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
helpText="Forward the original Host header to the backend instead of rewriting it to the target address."
|
||||||
|
/>
|
||||||
|
<FancyToggleSwitch
|
||||||
|
value={rewriteRedirects}
|
||||||
|
onChange={setRewriteRedirects}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<ArrowRight size={15} />
|
||||||
|
Rewrite Redirects
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
helpText="Rewrite Location headers in backend responses to use the public domain instead of the internal backend address."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<ModalFooter className={"items-center"}>
|
<ModalFooter className={"items-center"}>
|
||||||
<div className={"w-full"}>
|
<div className={"w-full"}>
|
||||||
{tab === "targets" && (
|
{(() => {
|
||||||
<Paragraph className={"text-sm mt-auto"}>
|
const docsLink = {
|
||||||
Learn more about
|
targets: {
|
||||||
<InlineLink
|
href: REVERSE_PROXY_SERVICES_DOCS_LINK,
|
||||||
href={REVERSE_PROXY_SERVICES_DOCS_LINK}
|
label: "Services",
|
||||||
target={"_blank"}
|
},
|
||||||
>
|
auth: {
|
||||||
Services
|
href: REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
|
||||||
<ExternalLinkIcon size={12} />
|
label: "Authentication",
|
||||||
</InlineLink>
|
},
|
||||||
</Paragraph>
|
settings: {
|
||||||
)}
|
href: REVERSE_PROXY_SETTINGS_DOCS_LINK,
|
||||||
|
label: "Settings",
|
||||||
{tab === "auth" && (
|
},
|
||||||
<Paragraph className={"text-sm mt-auto"}>
|
}[tab];
|
||||||
Learn more about
|
return docsLink ? (
|
||||||
<InlineLink
|
<Paragraph className={"text-sm mt-auto"}>
|
||||||
href={REVERSE_PROXY_AUTHENTICATION_DOCS_LINK}
|
Learn more about
|
||||||
target={"_blank"}
|
<InlineLink href={docsLink.href} target={"_blank"}>
|
||||||
>
|
{docsLink.label}
|
||||||
Authentication
|
<ExternalLinkIcon size={12} />
|
||||||
<ExternalLinkIcon size={12} />
|
</InlineLink>
|
||||||
</InlineLink>
|
</Paragraph>
|
||||||
</Paragraph>
|
) : null;
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
{tab === "settings" && (
|
|
||||||
<Paragraph className={"text-sm mt-auto"}>
|
|
||||||
Learn more about
|
|
||||||
<InlineLink
|
|
||||||
href={REVERSE_PROXY_SETTINGS_DOCS_LINK}
|
|
||||||
target={"_blank"}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
<ExternalLinkIcon size={12} />
|
|
||||||
</InlineLink>
|
|
||||||
</Paragraph>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={"flex gap-3 w-full justify-end"}>
|
<div className={"flex gap-3 w-full justify-end"}>
|
||||||
{!reverseProxy ? (
|
{!reverseProxy ? (
|
||||||
@@ -681,7 +653,7 @@ export default function ReverseProxyModal({
|
|||||||
</ModalClose>
|
</ModalClose>
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
onClick={() => setTab("auth")}
|
onClick={() => setTab(isL4Mode ? "settings" : "auth")}
|
||||||
disabled={!canContinueToSettings}
|
disabled={!canContinueToSettings}
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
@@ -710,13 +682,17 @@ export default function ReverseProxyModal({
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
onClick={() => setTab("auth")}
|
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
disabled={submitDisabled || !permission?.services?.create}
|
disabled={
|
||||||
|
!canContinueToSettings ||
|
||||||
|
!permission?.services?.create ||
|
||||||
|
!!timeoutError
|
||||||
|
}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
<PlusCircle size={16} />
|
<PlusCircle size={16} />
|
||||||
@@ -732,7 +708,11 @@ export default function ReverseProxyModal({
|
|||||||
</ModalClose>
|
</ModalClose>
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
disabled={submitDisabled || !permission?.services?.update}
|
disabled={
|
||||||
|
!canContinueToSettings ||
|
||||||
|
!permission?.services?.update ||
|
||||||
|
!!timeoutError
|
||||||
|
}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
Save Changes
|
Save Changes
|
||||||
@@ -760,6 +740,7 @@ export default function ReverseProxyModal({
|
|||||||
domain: fullDomain,
|
domain: fullDomain,
|
||||||
targets: targets,
|
targets: targets,
|
||||||
enabled: reverseProxy?.enabled ?? true,
|
enabled: reverseProxy?.enabled ?? true,
|
||||||
|
mode: serviceMode,
|
||||||
}}
|
}}
|
||||||
initialResource={initialResource}
|
initialResource={initialResource}
|
||||||
initialPeer={initialPeer}
|
initialPeer={initialPeer}
|
||||||
@@ -828,12 +809,3 @@ export default function ReverseProxyModal({
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
125
src/modules/reverse-proxy/ReverseProxyServiceModeSelector.tsx
Normal file
125
src/modules/reverse-proxy/ReverseProxyServiceModeSelector.tsx
Normal file
@@ -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";
|
} from "@/interfaces/ReverseProxy";
|
||||||
import HelpText from "@components/HelpText";
|
import HelpText from "@components/HelpText";
|
||||||
import Separator from "@components/Separator";
|
import Separator from "@components/Separator";
|
||||||
|
import { isNetBirdHosted } from "@/utils/netbird";
|
||||||
import {
|
import {
|
||||||
SelectDropdown,
|
SelectDropdown,
|
||||||
SelectOption,
|
SelectOption,
|
||||||
@@ -99,18 +100,35 @@ export const CustomDomainModal = ({
|
|||||||
|
|
||||||
<div className={"px-8 flex flex-col gap-6 pt-6 pb-8"}>
|
<div className={"px-8 flex flex-col gap-6 pt-6 pb-8"}>
|
||||||
{availableClusters.length === 0 ? (
|
{availableClusters.length === 0 ? (
|
||||||
<Callout variant="warning">
|
isNetBirdHosted() ? (
|
||||||
No proxy clusters are currently connected. Please ensure at least
|
<Callout variant={"warning"}>
|
||||||
one proxy is running before adding a domain. <br /> Learn more
|
No proxy clusters are currently connected. Please try again in a
|
||||||
about{" "}
|
few minutes. If the issue persists, check{" "}
|
||||||
<InlineLink
|
<InlineLink
|
||||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
href={"https://status.netbird.io/"}
|
||||||
target={"_blank"}
|
target={"_blank"}
|
||||||
>
|
>
|
||||||
Proxy Clusters
|
NetBird Status
|
||||||
<ExternalLinkIcon size={12} />
|
</InlineLink>{" "}
|
||||||
</InlineLink>
|
or reach out to{" "}
|
||||||
</Callout>
|
<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>
|
<div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from "@/interfaces/ReverseProxy";
|
} from "@/interfaces/ReverseProxy";
|
||||||
import Paragraph from "@components/Paragraph";
|
import Paragraph from "@components/Paragraph";
|
||||||
import InlineLink from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
|
import { isNetBirdHosted } from "@/utils/netbird";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -79,18 +80,36 @@ export const CustomDomainVerificationModal = ({
|
|||||||
</Steps>
|
</Steps>
|
||||||
<div className={"flex flex-col gap-6"}>
|
<div className={"flex flex-col gap-6"}>
|
||||||
{!cnameTarget ? (
|
{!cnameTarget ? (
|
||||||
<Callout variant={"warning"}>
|
isNetBirdHosted() ? (
|
||||||
No proxy clusters are currently connected. Please ensure at
|
<Callout variant={"warning"}>
|
||||||
least one proxy is running to configure DNS verification. <br />
|
No proxy clusters are currently connected. Please try again in
|
||||||
Learn more about{" "}
|
a few minutes. If the issue persists, check{" "}
|
||||||
<InlineLink
|
<InlineLink
|
||||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
href={"https://status.netbird.io/"}
|
||||||
target={"_blank"}
|
target={"_blank"}
|
||||||
>
|
>
|
||||||
Proxy Clusters
|
NetBird Status
|
||||||
<ExternalLinkIcon size={12} />
|
</InlineLink>{" "}
|
||||||
</InlineLink>
|
or reach out to{" "}
|
||||||
</Callout>
|
<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"}>
|
<Card className={"w-full"}>
|
||||||
|
|||||||
84
src/modules/reverse-proxy/domain/ReverseProxyDomainInput.tsx
Normal file
84
src/modules/reverse-proxy/domain/ReverseProxyDomainInput.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/modules/reverse-proxy/domain/useReverseProxyDomain.ts
Normal file
133
src/modules/reverse-proxy/domain/useReverseProxyDomain.ts
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||||
|
import { formatDuration } from "@utils/helpers";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: ReverseProxyEvent;
|
event: ReverseProxyEvent;
|
||||||
@@ -8,7 +9,7 @@ type Props = {
|
|||||||
export const ReverseProxyEventsDurationCell = ({ event }: Props) => {
|
export const ReverseProxyEventsDurationCell = ({ event }: Props) => {
|
||||||
return (
|
return (
|
||||||
<span className="text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono">
|
<span className="text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono">
|
||||||
{event.duration_ms}ms
|
{formatDuration(event.duration_ms)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||||
import TruncatedText from "@components/ui/TruncatedText";
|
import TruncatedText from "@components/ui/TruncatedText";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
import {
|
||||||
|
isL4Event,
|
||||||
|
ReverseProxy,
|
||||||
|
ReverseProxyEvent,
|
||||||
|
} from "@/interfaces/ReverseProxy";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: ReverseProxyEvent;
|
event: ReverseProxyEvent;
|
||||||
|
service?: ReverseProxy;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReverseProxyEventsMethodCell = ({ event }: Props) => {
|
export const ReverseProxyEventsMethodCell = ({ event }: Props) => {
|
||||||
|
if (isL4Event(event)) {
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-[0.82rem] font-medium py-2 text-nb-gray-200 uppercase">
|
||||||
|
{event.protocol}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="font-mono text-[0.82rem] font-medium py-2 text-nb-gray-300">
|
<span className="font-mono text-[0.82rem] font-medium py-2 text-nb-gray-300">
|
||||||
{event.method}
|
{event.method}
|
||||||
@@ -15,8 +28,13 @@ export const ReverseProxyEventsMethodCell = ({ event }: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReverseProxyEventsUrlCell = ({ event }: Props) => {
|
export const ReverseProxyEventsUrlCell = ({ event, service }: Props) => {
|
||||||
const fullUrl = `${event.host}${event.path}`;
|
const isL4 = isL4Event(event);
|
||||||
|
const listenPort = service?.listen_port;
|
||||||
|
|
||||||
|
const hostWithPort =
|
||||||
|
isL4 && listenPort ? `${event.host}:${listenPort}` : event.host || "-";
|
||||||
|
const fullUrl = isL4 ? hostWithPort : `${event.host}${event.path}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TruncatedText
|
<TruncatedText
|
||||||
@@ -34,7 +52,12 @@ export const ReverseProxyEventsUrlCell = ({ event }: Props) => {
|
|||||||
<CopyToClipboardText message={"URL has been copied to your clipboard"}>
|
<CopyToClipboardText message={"URL has been copied to your clipboard"}>
|
||||||
<span className="font-mono text-[0.82rem] whitespace-nowrap">
|
<span className="font-mono text-[0.82rem] whitespace-nowrap">
|
||||||
<span className="text-nb-gray-200">{event.host}</span>
|
<span className="text-nb-gray-200">{event.host}</span>
|
||||||
<span className="text-nb-gray-300">{event.path}</span>
|
{isL4 && listenPort && (
|
||||||
|
<span className="text-nb-gray-300">:{listenPort}</span>
|
||||||
|
)}
|
||||||
|
{!isL4 && (
|
||||||
|
<span className="text-nb-gray-300">{event.path}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</CopyToClipboardText>
|
</CopyToClipboardText>
|
||||||
</TruncatedText>
|
</TruncatedText>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
import { isL4Event, ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||||
import Badge from "@components/Badge";
|
import Badge from "@components/Badge";
|
||||||
|
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
event: ReverseProxyEvent;
|
event: ReverseProxyEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReverseProxyEventsStatusCell = ({ event }: Props) => {
|
export const ReverseProxyEventsStatusCell = ({ event }: Props) => {
|
||||||
|
if (isL4Event(event)) return <EmptyRow />;
|
||||||
const isSuccess = event.status_code >= 200 && event.status_code < 400;
|
const isSuccess = event.status_code >= 200 && event.status_code < 400;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import { DatePickerWithRange } from "@components/DatePickerWithRange";
|
|||||||
import { useServerPagination } from "@/contexts/ServerPaginationProvider";
|
import { useServerPagination } from "@/contexts/ServerPaginationProvider";
|
||||||
import {
|
import {
|
||||||
REVERSE_PROXY_EVENTS_DOCS_LINK,
|
REVERSE_PROXY_EVENTS_DOCS_LINK,
|
||||||
|
ReverseProxy,
|
||||||
ReverseProxyEvent,
|
ReverseProxyEvent,
|
||||||
} from "@/interfaces/ReverseProxy";
|
} from "@/interfaces/ReverseProxy";
|
||||||
|
import useFetchApi from "@/utils/api";
|
||||||
import { ReverseProxyEventsStatusCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsStatusCell";
|
import { ReverseProxyEventsStatusCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsStatusCell";
|
||||||
import { ReverseProxyEventsUserCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsUserCell";
|
import { ReverseProxyEventsUserCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsUserCell";
|
||||||
import { ReverseProxyEventsLocationIpCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell";
|
import { ReverseProxyEventsLocationIpCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell";
|
||||||
@@ -31,8 +33,11 @@ import { ReverseProxyEventsTimeCell } from "@/modules/reverse-proxy/events/Rever
|
|||||||
import { ReverseProxyEventsAuthMethodCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell";
|
import { ReverseProxyEventsAuthMethodCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell";
|
||||||
import { ReverseProxyEventsReasonCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsReasonCell";
|
import { ReverseProxyEventsReasonCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsReasonCell";
|
||||||
import { ReverseProxyEventsDurationCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsDurationCell";
|
import { ReverseProxyEventsDurationCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsDurationCell";
|
||||||
|
import { ReverseProxyEventsBytesCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsBytesCell";
|
||||||
|
|
||||||
export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
export const makeEventsColumns = (
|
||||||
|
servicesMap: Map<string, ReverseProxy>,
|
||||||
|
): ColumnDef<ReverseProxyEvent>[] => [
|
||||||
{
|
{
|
||||||
id: "timestamp",
|
id: "timestamp",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
@@ -73,13 +78,18 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "url",
|
id: "url",
|
||||||
accessorFn: (row) => `${row.host} ${row.path}`,
|
accessorFn: (row) => `${row.host} ${row.path || ""}`,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableHeader column={column} name="url">
|
<DataTableHeader column={column} name="url" sorting={false}>
|
||||||
URL
|
Host / URL
|
||||||
</DataTableHeader>
|
</DataTableHeader>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <ReverseProxyEventsUrlCell event={row.original} />,
|
cell: ({ row }) => (
|
||||||
|
<ReverseProxyEventsUrlCell
|
||||||
|
event={row.original}
|
||||||
|
service={servicesMap.get(row.original.service_id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
@@ -108,6 +118,16 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => <ReverseProxyEventsDurationCell event={row.original} />,
|
cell: ({ row }) => <ReverseProxyEventsDurationCell event={row.original} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "bytes",
|
||||||
|
accessorFn: (row) => (row.bytes_download ?? 0) + (row.bytes_upload ?? 0),
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataTableHeader column={column} sorting={false}>
|
||||||
|
Bytes
|
||||||
|
</DataTableHeader>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <ReverseProxyEventsBytesCell event={row.original} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "auth_method",
|
id: "auth_method",
|
||||||
accessorKey: "auth_method_used",
|
accessorKey: "auth_method_used",
|
||||||
@@ -162,6 +182,20 @@ export default function ReverseProxyEventsTable({
|
|||||||
...paginationProps
|
...paginationProps
|
||||||
} = useServerPagination<ReverseProxyEvent[]>();
|
} = useServerPagination<ReverseProxyEvent[]>();
|
||||||
|
|
||||||
|
const { data: services } = useFetchApi<ReverseProxy[]>(
|
||||||
|
"/reverse-proxies/services",
|
||||||
|
);
|
||||||
|
|
||||||
|
const servicesMap = useMemo(() => {
|
||||||
|
const map = new Map<string, ReverseProxy>();
|
||||||
|
for (const svc of services ?? []) {
|
||||||
|
if (svc.id) map.set(svc.id, svc);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [services]);
|
||||||
|
|
||||||
|
const columns = useMemo(() => makeEventsColumns(servicesMap), [servicesMap]);
|
||||||
|
|
||||||
const activeStatus = getFilter("status");
|
const activeStatus = getFilter("status");
|
||||||
|
|
||||||
const dateRange = useMemo<DateRange | undefined>(() => {
|
const dateRange = useMemo<DateRange | undefined>(() => {
|
||||||
@@ -206,7 +240,7 @@ export default function ReverseProxyEventsTable({
|
|||||||
text={"Proxy Events"}
|
text={"Proxy Events"}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
setSorting={setSorting}
|
setSorting={setSorting}
|
||||||
columns={ReverseProxyEventsTableColumns}
|
columns={columns}
|
||||||
columnVisibility={{ is_success: false, id: false }}
|
columnVisibility={{ is_success: false, id: false }}
|
||||||
searchPlaceholder={"Search by IP, host, path, user..."}
|
searchPlaceholder={"Search by IP, host, path, user..."}
|
||||||
getStartedCard={
|
getStartedCard={
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { UserCountStack } from "@components/ui/MultipleGroups";
|
|||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
Binary,
|
Binary,
|
||||||
|
HelpCircle,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
RectangleEllipsis,
|
RectangleEllipsis,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -23,7 +24,8 @@ import { useGroups } from "@/contexts/GroupsProvider";
|
|||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
import { Group } from "@/interfaces/Group";
|
import { Group } from "@/interfaces/Group";
|
||||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
import { isL4Mode, ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||||
|
import FullTooltip from "@components/FullTooltip";
|
||||||
|
|
||||||
const AUTH_METHODS: {
|
const AUTH_METHODS: {
|
||||||
key: "password_auth" | "pin_auth" | "bearer_auth";
|
key: "password_auth" | "pin_auth" | "bearer_auth";
|
||||||
@@ -56,6 +58,28 @@ export default function ReverseProxyAuthCell({
|
|||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
const { openModal } = useReverseProxies();
|
const { openModal } = useReverseProxies();
|
||||||
const { groups } = useGroups();
|
const { groups } = useGroups();
|
||||||
|
|
||||||
|
// L4 services don't support auth
|
||||||
|
if (isL4Mode(reverseProxy.mode)) {
|
||||||
|
return (
|
||||||
|
<div className={"flex"}>
|
||||||
|
<FullTooltip
|
||||||
|
content={
|
||||||
|
<div className={"flex text-xs max-w-[340px]"}>
|
||||||
|
Auth methods are not supported for TCP/UDP and TLS passthrough
|
||||||
|
services as they operate at the network layer.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Badge variant={"gray"}>
|
||||||
|
N/A
|
||||||
|
<HelpCircle size={12} />
|
||||||
|
</Badge>
|
||||||
|
</FullTooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const auth = reverseProxy.auth;
|
const auth = reverseProxy.auth;
|
||||||
|
|
||||||
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
|
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
|
||||||
@@ -71,20 +95,17 @@ export default function ReverseProxyAuthCell({
|
|||||||
|
|
||||||
const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null;
|
const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null;
|
||||||
|
|
||||||
const badgeContent =
|
const badgeContent = SingleIcon ? (
|
||||||
SingleIcon ? (
|
<>
|
||||||
<>
|
<SingleIcon size={12} className="text-green-500" />
|
||||||
<SingleIcon size={12} className="text-green-500" />
|
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
|
||||||
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
|
</>
|
||||||
</>
|
) : enabled.length > 1 ? (
|
||||||
) : enabled.length > 1 ? (
|
<>
|
||||||
<>
|
<ShieldCheck size={12} className="text-green-400" />
|
||||||
<ShieldCheck size={12} className="text-green-400" />
|
<span className={"font-medium text-xs"}>{enabled.length} Enabled</span>
|
||||||
<span className={"font-medium text-xs"}>
|
</>
|
||||||
{enabled.length} Enabled
|
) : null;
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -137,9 +158,7 @@ export default function ReverseProxyAuthCell({
|
|||||||
{ssoGroups.map((group) => (
|
{ssoGroups.map((group) => (
|
||||||
<div
|
<div
|
||||||
key={group.id}
|
key={group.id}
|
||||||
className={
|
className={"flex gap-2 items-center justify-between"}
|
||||||
"flex gap-2 items-center justify-between"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<GroupBadge group={group} />
|
<GroupBadge group={group} />
|
||||||
<ArrowRightIcon size={14} />
|
<ArrowRightIcon size={14} />
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
ReverseProxyDomainType,
|
ReverseProxyDomainType,
|
||||||
} from "@/interfaces/ReverseProxy";
|
} from "@/interfaces/ReverseProxy";
|
||||||
import FullTooltip from "@components/FullTooltip";
|
import FullTooltip from "@components/FullTooltip";
|
||||||
|
import { isNetBirdHosted } from "@/utils/netbird";
|
||||||
|
import InlineLink from "@components/InlineLink";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
reverseProxy: ReverseProxy;
|
reverseProxy: ReverseProxy;
|
||||||
@@ -52,10 +54,24 @@ export default function ReverseProxyClusterCell({
|
|||||||
return (
|
return (
|
||||||
<FullTooltip
|
<FullTooltip
|
||||||
content={
|
content={
|
||||||
<div className={"flex flex-col gap-1 text-xs max-w-xs"}>
|
isNetBirdHosted() ? (
|
||||||
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the proxy
|
<div className={"text-xs max-w-xs"}>
|
||||||
server is running and connected to the right management address.
|
Cluster {reverseProxy.proxy_cluster} is offline. Please try again in
|
||||||
</div>
|
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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={"flex flex-col gap-1 text-xs max-w-xs"}>
|
||||||
|
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the proxy
|
||||||
|
server is running and connected to the right management address.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
align={"center"}
|
align={"center"}
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import { ChevronDown, ChevronRightIcon, LockIcon } from "lucide-react";
|
import { ChevronDown, ChevronRightIcon, LockIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
import { isL4Mode, ReverseProxy, ServiceMode } from "@/interfaces/ReverseProxy";
|
||||||
import ExternalLinkText from "@components/ExternalLinkText";
|
import ExternalLinkText from "@components/ExternalLinkText";
|
||||||
|
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
reverseProxy?: ReverseProxy;
|
reverseProxy?: ReverseProxy;
|
||||||
@@ -18,8 +19,14 @@ export default function ReverseProxyNameCell({
|
|||||||
showChevron = true,
|
showChevron = true,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const displayDomain = domain ?? reverseProxy?.domain ?? "";
|
const displayDomain = domain ?? reverseProxy?.domain ?? "";
|
||||||
|
const isL4 = reverseProxy?.mode && isL4Mode(reverseProxy.mode);
|
||||||
|
const portSuffix =
|
||||||
|
isL4 && reverseProxy?.listen_port ? `:${reverseProxy.listen_port}` : "";
|
||||||
|
const isLinkable = !isL4 || reverseProxy?.mode === ServiceMode.TLS;
|
||||||
|
|
||||||
const isEnabled = enabled ?? reverseProxy?.enabled ?? false;
|
const isEnabled = enabled ?? reverseProxy?.enabled ?? false;
|
||||||
const hasTargets = (reverseProxy?.targets?.length ?? 0) > 0;
|
const hasExpandableTargets =
|
||||||
|
(reverseProxy?.targets?.length ?? 0) > 0 && !isL4Mode(reverseProxy?.mode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -36,14 +43,14 @@ export default function ReverseProxyNameCell({
|
|||||||
size={20}
|
size={20}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group-data-[accordion=opened]/accordion:hidden text-nb-gray-400 shrink-0",
|
"group-data-[accordion=opened]/accordion:hidden text-nb-gray-400 shrink-0",
|
||||||
!hasTargets && "cursor-default opacity-0",
|
!hasExpandableTargets && "cursor-default opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={20}
|
size={20}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group-data-[accordion=closed]/accordion:hidden text-nb-gray-400 shrink-0",
|
"group-data-[accordion=closed]/accordion:hidden text-nb-gray-400 shrink-0",
|
||||||
!hasTargets && "cursor-default opacity-0",
|
!hasExpandableTargets && "cursor-default opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -57,13 +64,21 @@ export default function ReverseProxyNameCell({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 truncate">
|
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 truncate">
|
||||||
{displayDomain ? (
|
<div className="flex items-center gap-2">
|
||||||
<ExternalLinkText href={`https://${displayDomain}`}>
|
{displayDomain && isLinkable ? (
|
||||||
<span className="font-medium truncate">{displayDomain}</span>
|
<ExternalLinkText href={`https://${displayDomain}${portSuffix}`}>
|
||||||
</ExternalLinkText>
|
<span className="font-medium truncate">
|
||||||
) : (
|
{displayDomain}
|
||||||
<span className="font-medium truncate">{displayDomain}</span>
|
{portSuffix}
|
||||||
)}
|
</span>
|
||||||
|
</ExternalLinkText>
|
||||||
|
) : (
|
||||||
|
<CopyToClipboardText>
|
||||||
|
{displayDomain}
|
||||||
|
{portSuffix}
|
||||||
|
</CopyToClipboardText>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
|
REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK,
|
||||||
ReverseProxy,
|
ReverseProxy,
|
||||||
ReverseProxyMeta,
|
ReverseProxyMeta,
|
||||||
ReverseProxyStatus,
|
ReverseProxyStatus,
|
||||||
} from "@/interfaces/ReverseProxy";
|
} from "@/interfaces/ReverseProxy";
|
||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
import Badge from "@components/Badge";
|
import Badge from "@components/Badge";
|
||||||
import { Loader2 } from "lucide-react";
|
import FullTooltip from "@components/FullTooltip";
|
||||||
|
import InlineLink from "@components/InlineLink";
|
||||||
|
import { CircleAlert, Loader2 } from "lucide-react";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
meta?: ReverseProxyMeta;
|
meta?: ReverseProxyMeta;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
isL4?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 3500;
|
const POLL_INTERVAL_MS = 3500;
|
||||||
@@ -20,6 +24,7 @@ export default function ReverseProxyStatusCell({
|
|||||||
serviceId,
|
serviceId,
|
||||||
meta,
|
meta,
|
||||||
enabled,
|
enabled,
|
||||||
|
isL4,
|
||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const dataRef = useRef<ReverseProxy | undefined>(undefined);
|
const dataRef = useRef<ReverseProxy | undefined>(undefined);
|
||||||
|
|
||||||
@@ -27,11 +32,19 @@ export default function ReverseProxyStatusCell({
|
|||||||
meta?.status === ReverseProxyStatus.ACTIVE ||
|
meta?.status === ReverseProxyStatus.ACTIVE ||
|
||||||
dataRef.current?.meta?.status === ReverseProxyStatus.ACTIVE;
|
dataRef.current?.meta?.status === ReverseProxyStatus.ACTIVE;
|
||||||
|
|
||||||
|
const hasError =
|
||||||
|
meta?.status === ReverseProxyStatus.ERROR ||
|
||||||
|
dataRef.current?.meta?.status === ReverseProxyStatus.ERROR;
|
||||||
|
|
||||||
|
const isTunnelNotCreated =
|
||||||
|
meta?.status === ReverseProxyStatus.TUNNEL_NOT_CREATED ||
|
||||||
|
dataRef.current?.meta?.status === ReverseProxyStatus.TUNNEL_NOT_CREATED;
|
||||||
|
|
||||||
const certificateIssued =
|
const certificateIssued =
|
||||||
!!meta?.certificate_issued_at ||
|
!!meta?.certificate_issued_at ||
|
||||||
!!dataRef.current?.meta?.certificate_issued_at;
|
!!dataRef.current?.meta?.certificate_issued_at;
|
||||||
|
|
||||||
const shouldPoll = !!enabled && !(isActive && certificateIssued);
|
const shouldPoll = !!enabled && !(isActive && (isL4 || certificateIssued));
|
||||||
|
|
||||||
const { data } = useFetchApi<ReverseProxy>(
|
const { data } = useFetchApi<ReverseProxy>(
|
||||||
`/reverse-proxies/services/${serviceId}`,
|
`/reverse-proxies/services/${serviceId}`,
|
||||||
@@ -43,7 +56,70 @@ export default function ReverseProxyStatusCell({
|
|||||||
|
|
||||||
dataRef.current = data;
|
dataRef.current = data;
|
||||||
|
|
||||||
if (!enabled || (isActive && certificateIssued)) {
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
// L4 services don't need certificates
|
||||||
|
if (isL4) {
|
||||||
|
if (isActive) return null;
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<FullTooltip
|
||||||
|
content={
|
||||||
|
<div className={"text-xs max-w-xs"}>
|
||||||
|
Something went wrong while setting up this service. See our{" "}
|
||||||
|
<InlineLink
|
||||||
|
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Troubleshooting Docs
|
||||||
|
</InlineLink>{" "}
|
||||||
|
for more details.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
align={"center"}
|
||||||
|
alignOffset={0}
|
||||||
|
>
|
||||||
|
<div className={"flex"}>
|
||||||
|
<Badge variant={"red"}>
|
||||||
|
<CircleAlert size={11} />
|
||||||
|
Error
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</FullTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isTunnelNotCreated) {
|
||||||
|
return (
|
||||||
|
<FullTooltip
|
||||||
|
content={
|
||||||
|
<div className={"text-xs max-w-xs"}>
|
||||||
|
The tunnel to the target peer could not be established. See our{" "}
|
||||||
|
<InlineLink
|
||||||
|
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
|
||||||
|
target={"_blank"}
|
||||||
|
>
|
||||||
|
Troubleshooting Docs
|
||||||
|
</InlineLink>{" "}
|
||||||
|
for more details.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
align={"center"}
|
||||||
|
alignOffset={0}
|
||||||
|
>
|
||||||
|
<div className={"flex"}>
|
||||||
|
<Badge variant={"red"}>
|
||||||
|
<CircleAlert size={11} />
|
||||||
|
Tunnel not created
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</FullTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <SettingUpService />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP services: hide once active with certificate issued
|
||||||
|
if (isActive && certificateIssued) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +134,10 @@ export default function ReverseProxyStatusCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return <SettingUpService />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingUpService = () => {
|
||||||
return (
|
return (
|
||||||
<div className={"flex"}>
|
<div className={"flex"}>
|
||||||
<Badge variant={"yellow"}>
|
<Badge variant={"yellow"}>
|
||||||
@@ -66,4 +146,4 @@ export default function ReverseProxyStatusCell({
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
|||||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
import {
|
import {
|
||||||
|
isL4Mode,
|
||||||
REVERSE_PROXY_DOCS_LINK,
|
REVERSE_PROXY_DOCS_LINK,
|
||||||
ReverseProxy,
|
ReverseProxy,
|
||||||
} from "@/interfaces/ReverseProxy";
|
} from "@/interfaces/ReverseProxy";
|
||||||
@@ -28,6 +29,7 @@ import ReverseProxyNameCell from "@/modules/reverse-proxy/table/ReverseProxyName
|
|||||||
import ReverseProxyTargetsCell from "@/modules/reverse-proxy/table/ReverseProxyTargetsCell";
|
import ReverseProxyTargetsCell from "@/modules/reverse-proxy/table/ReverseProxyTargetsCell";
|
||||||
import ReverseProxyTargetsTable from "@/modules/reverse-proxy/targets/ReverseProxyTargetsTable";
|
import ReverseProxyTargetsTable from "@/modules/reverse-proxy/targets/ReverseProxyTargetsTable";
|
||||||
import ReverseProxyStatusCell from "@/modules/reverse-proxy/table/ReverseProxyStatusCell";
|
import ReverseProxyStatusCell from "@/modules/reverse-proxy/table/ReverseProxyStatusCell";
|
||||||
|
import { ReverseProxyTypeCell } from "@/modules/reverse-proxy/table/ReverseProxyTypeCell";
|
||||||
|
|
||||||
const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
||||||
{
|
{
|
||||||
@@ -38,6 +40,14 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
|||||||
sortingFn: "text",
|
sortingFn: "text",
|
||||||
cell: ({ row }) => <ReverseProxyNameCell reverseProxy={row.original} />,
|
cell: ({ row }) => <ReverseProxyNameCell reverseProxy={row.original} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "mode",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return <DataTableHeader column={column}>Type</DataTableHeader>;
|
||||||
|
},
|
||||||
|
sortingFn: "text",
|
||||||
|
cell: ({ row }) => <ReverseProxyTypeCell reverseProxy={row.original} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
accessorFn: (proxy) => proxy?.meta?.certificate_issued_at,
|
accessorFn: (proxy) => proxy?.meta?.certificate_issued_at,
|
||||||
@@ -48,6 +58,7 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
|||||||
serviceId={row.original.id}
|
serviceId={row.original.id}
|
||||||
meta={row.original.meta}
|
meta={row.original.meta}
|
||||||
enabled={row.original.enabled}
|
enabled={row.original.enabled}
|
||||||
|
isL4={isL4Mode(row.original.mode)}
|
||||||
/>
|
/>
|
||||||
) : null,
|
) : null,
|
||||||
},
|
},
|
||||||
@@ -68,7 +79,7 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "targets",
|
accessorKey: "targets",
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
return <DataTableHeader column={column}>Targets</DataTableHeader>;
|
return <DataTableHeader column={column}>Target(s)</DataTableHeader>;
|
||||||
},
|
},
|
||||||
cell: ({ row }) => <ReverseProxyTargetsCell reverseProxy={row.original} />,
|
cell: ({ row }) => <ReverseProxyTargetsCell reverseProxy={row.original} />,
|
||||||
},
|
},
|
||||||
@@ -131,7 +142,9 @@ export default function ReverseProxyTable({ headingTarget }: Readonly<Props>) {
|
|||||||
useRowId={true}
|
useRowId={true}
|
||||||
searchPlaceholder={"Search by URL, domain, or target..."}
|
searchPlaceholder={"Search by URL, domain, or target..."}
|
||||||
columnVisibility={{ searchString: false }}
|
columnVisibility={{ searchString: false }}
|
||||||
|
tableCellClassName={"h-[80px]"}
|
||||||
renderExpandedRow={(reverseProxy) => {
|
renderExpandedRow={(reverseProxy) => {
|
||||||
|
if (isL4Mode(reverseProxy.mode)) return;
|
||||||
const hasTargets = (reverseProxy?.targets?.length ?? 0) > 0;
|
const hasTargets = (reverseProxy?.targets?.length ?? 0) > 0;
|
||||||
if (!hasTargets) return;
|
if (!hasTargets) return;
|
||||||
return (
|
return (
|
||||||
@@ -159,7 +172,6 @@ export default function ReverseProxyTable({ headingTarget }: Readonly<Props>) {
|
|||||||
button={
|
button={
|
||||||
<Button
|
<Button
|
||||||
variant={"primary"}
|
variant={"primary"}
|
||||||
className={""}
|
|
||||||
onClick={() => openModal()}
|
onClick={() => openModal()}
|
||||||
disabled={!permission?.services?.create}
|
disabled={!permission?.services?.create}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { PlusCircle, Server } from "lucide-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
import { isL4Mode, ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||||
|
import { ReverseProxyTargetDevice } from "@/modules/reverse-proxy/targets/ReverseProxyTargetDevice";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
reverseProxy: ReverseProxy;
|
reverseProxy: ReverseProxy;
|
||||||
@@ -16,6 +17,22 @@ export default function ReverseProxyTargetsCell({
|
|||||||
const { permission } = usePermissions();
|
const { permission } = usePermissions();
|
||||||
const { openTargetModal } = useReverseProxies();
|
const { openTargetModal } = useReverseProxies();
|
||||||
|
|
||||||
|
if (isL4Mode(reverseProxy.mode)) {
|
||||||
|
const target = reverseProxy?.targets?.[0];
|
||||||
|
const address = target.host
|
||||||
|
? `${target.host}:${target.port}`
|
||||||
|
: `:${target.port}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReverseProxyTargetDevice
|
||||||
|
target={target}
|
||||||
|
address={address}
|
||||||
|
wrapperClassName={"h-[48px]"}
|
||||||
|
skeletonClassName={"h-[48px]"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const targetsCount = reverseProxy?.targets?.length ?? 0;
|
const targetsCount = reverseProxy?.targets?.length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
71
src/modules/reverse-proxy/table/ReverseProxyTypeCell.tsx
Normal file
71
src/modules/reverse-proxy/table/ReverseProxyTypeCell.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { ReverseProxy, ServiceMode } from "@/interfaces/ReverseProxy";
|
||||||
|
import { trim } from "lodash";
|
||||||
|
import { SERVICE_MODES } from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
|
||||||
|
import Badge from "@components/Badge";
|
||||||
|
import { cn } from "@utils/helpers";
|
||||||
|
import { ArrowRightFromLineIcon, GlobeIcon, LockKeyhole } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
reverseProxy?: ReverseProxy;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReverseProxyTypeCell = ({ reverseProxy }: Props) => {
|
||||||
|
const serviceModeLabel = useMemo(() => {
|
||||||
|
if (!reverseProxy?.mode) return "HTTP/S";
|
||||||
|
const mode = SERVICE_MODES[reverseProxy.mode];
|
||||||
|
if (!mode) return "HTTP/S";
|
||||||
|
return trim(mode.label.replace("Service", ""));
|
||||||
|
}, [reverseProxy]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={"flex"}>
|
||||||
|
<Badge variant={"gray"} className={"font-normal"}>
|
||||||
|
<ReverseProxyServiceIcon
|
||||||
|
reverseProxy={reverseProxy}
|
||||||
|
className={"text-nb-gray-200"}
|
||||||
|
size={11}
|
||||||
|
/>
|
||||||
|
{serviceModeLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReverseProxyServiceIconProps = {
|
||||||
|
reverseProxy?: ReverseProxy;
|
||||||
|
className?: string;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReverseProxyServiceIcon = ({
|
||||||
|
reverseProxy,
|
||||||
|
className,
|
||||||
|
size = 14,
|
||||||
|
}: ReverseProxyServiceIconProps) => {
|
||||||
|
const mode = reverseProxy?.mode;
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case ServiceMode.HTTP:
|
||||||
|
return <GlobeIcon size={size} className={cn("shrink-0", className)} />;
|
||||||
|
case ServiceMode.TLS:
|
||||||
|
return <LockKeyhole size={size} className={cn("shrink-0", className)} />;
|
||||||
|
case ServiceMode.TCP:
|
||||||
|
return (
|
||||||
|
<ArrowRightFromLineIcon
|
||||||
|
size={size}
|
||||||
|
className={cn("shrink-0", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case ServiceMode.UDP:
|
||||||
|
return (
|
||||||
|
<ArrowRightFromLineIcon
|
||||||
|
size={size}
|
||||||
|
className={cn("shrink-0", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <GlobeIcon size={size} className={cn("shrink-0", className)} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
100
src/modules/reverse-proxy/targets/ReverseProxyAddressInput.tsx
Normal file
100
src/modules/reverse-proxy/targets/ReverseProxyAddressInput.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Input } from "@components/Input";
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import cidr from "ip-cidr";
|
||||||
|
import type { Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
|
||||||
|
import { ReverseProxyTargetType } from "@/interfaces/ReverseProxy";
|
||||||
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
|
import { cn } from "@utils/helpers";
|
||||||
|
import { HelpTooltip } from "@components/HelpTooltip";
|
||||||
|
|
||||||
|
export function useReverseProxyAddress(target: Target | undefined) {
|
||||||
|
const { resources } = useReverseProxies();
|
||||||
|
const resource = useMemo(
|
||||||
|
() => resources?.find((r) => r.id === target?.resourceId),
|
||||||
|
[resources, target?.resourceId],
|
||||||
|
);
|
||||||
|
const resourceAddress = resource?.address || "";
|
||||||
|
|
||||||
|
const isCidrRange = useMemo(() => {
|
||||||
|
if (target?.type === ReverseProxyTargetType.SUBNET) return true;
|
||||||
|
if (!resourceAddress) return false;
|
||||||
|
if (!cidr.isValidCIDR(resourceAddress)) return false;
|
||||||
|
const parts = resourceAddress.split("/");
|
||||||
|
const mask = parts.length === 2 ? parseInt(parts[1], 10) : 32;
|
||||||
|
return mask < 32;
|
||||||
|
}, [target?.type, resourceAddress]);
|
||||||
|
|
||||||
|
const cidrInfo = useMemo(() => {
|
||||||
|
if (!resourceAddress) return null;
|
||||||
|
if (!cidr.isValidCIDR(resourceAddress)) return null;
|
||||||
|
try {
|
||||||
|
return new cidr(resourceAddress);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [resourceAddress]);
|
||||||
|
|
||||||
|
const isHostEditable = isCidrRange;
|
||||||
|
|
||||||
|
const isHostInCidrRange = useMemo(() => {
|
||||||
|
if (!cidrInfo || !target?.host) return false;
|
||||||
|
if (!cidr.isValidAddress(target.host)) return false;
|
||||||
|
return cidrInfo.contains(target.host);
|
||||||
|
}, [cidrInfo, target?.host]);
|
||||||
|
|
||||||
|
const isValidCidrHost =
|
||||||
|
!isCidrRange ||
|
||||||
|
(!!target?.host && !!cidrInfo && isHostInCidrRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
resourceAddress,
|
||||||
|
cidrInfo,
|
||||||
|
isCidrRange,
|
||||||
|
isHostEditable,
|
||||||
|
isHostInCidrRange,
|
||||||
|
isValidCidrHost,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CidrHelpText({ target }: { target: Target | undefined }) {
|
||||||
|
const { cidrInfo, resourceAddress } = useReverseProxyAddress(target);
|
||||||
|
if (!cidrInfo) return null;
|
||||||
|
return (
|
||||||
|
<HelpTooltip content={`Enter an IP address within ${resourceAddress}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: Target | undefined;
|
||||||
|
onChange: React.Dispatch<React.SetStateAction<Target | undefined>>;
|
||||||
|
className?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ReverseProxyAddressInput({
|
||||||
|
value: target,
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
autoFocus,
|
||||||
|
}: Readonly<Props>) {
|
||||||
|
const { isHostEditable } = useReverseProxyAddress(target);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={target?.host ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const host = isHostEditable
|
||||||
|
? e.target.value.replace(/[^0-9.]/g, "")
|
||||||
|
: e.target.value;
|
||||||
|
onChange((prev) => prev && { ...prev, host });
|
||||||
|
}}
|
||||||
|
maxWidthClass={"w-full"}
|
||||||
|
customSuffix={":"}
|
||||||
|
placeholder="e.g., 192.168.0.10"
|
||||||
|
disabled={!target}
|
||||||
|
readOnly={target && !isHostEditable ? true : undefined}
|
||||||
|
className={cn("rounded-r-none border-r-0", className)}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,11 +17,19 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
|||||||
type Props = {
|
type Props = {
|
||||||
target: ReverseProxyTarget;
|
target: ReverseProxyTarget;
|
||||||
showDescription?: boolean;
|
showDescription?: boolean;
|
||||||
|
wrapperClassName?: string;
|
||||||
|
skeletonClassName?: string;
|
||||||
|
deviceClassName?: string;
|
||||||
|
address?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReverseProxyTargetDevice = ({
|
export const ReverseProxyTargetDevice = ({
|
||||||
target,
|
target,
|
||||||
showDescription,
|
showDescription,
|
||||||
|
wrapperClassName = "h-[59px]",
|
||||||
|
skeletonClassName = "min-h-[59px]",
|
||||||
|
deviceClassName = "",
|
||||||
|
address,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: peers, isLoading: isPeersLoading } =
|
const { data: peers, isLoading: isPeersLoading } =
|
||||||
@@ -55,17 +63,19 @@ export const ReverseProxyTargetDevice = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isPeersLoading || isResourceLoading || isNetworksLoading)
|
if (isPeersLoading || isResourceLoading || isNetworksLoading)
|
||||||
return <SkeletonDeviceCard />;
|
return <SkeletonDeviceCard className={skeletonClassName} />;
|
||||||
|
|
||||||
if (!peer && !resource)
|
if (!peer && !resource)
|
||||||
return (
|
return (
|
||||||
<div className={"min-h-[59px] flex items-center relative left-1"}>
|
<div
|
||||||
|
className={cn("flex items-center relative left-1", wrapperClassName)}
|
||||||
|
>
|
||||||
<EmptyRow />
|
<EmptyRow />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={"min-h-[59px] flex items-center relative -left-2"}>
|
<div className={cn("flex items-center relative -left-2", wrapperClassName)}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer rounded-md hover:bg-nb-gray-900/40 flex items-center justify-between group pr-4",
|
"cursor-pointer rounded-md hover:bg-nb-gray-900/40 flex items-center justify-between group pr-4",
|
||||||
@@ -73,8 +83,13 @@ export const ReverseProxyTargetDevice = ({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<DeviceCard
|
<DeviceCard
|
||||||
|
address={address}
|
||||||
device={peer}
|
device={peer}
|
||||||
className={cn(!target.enabled && "opacity-40", "pl-2")}
|
className={cn(
|
||||||
|
!target.enabled && "opacity-40",
|
||||||
|
"pl-2",
|
||||||
|
deviceClassName,
|
||||||
|
)}
|
||||||
resource={resource}
|
resource={resource}
|
||||||
description={showDescription ? resource?.description : undefined}
|
description={showDescription ? resource?.description : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@components/Accordion";
|
||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||||
import HelpText from "@components/HelpText";
|
import HelpText from "@components/HelpText";
|
||||||
@@ -7,22 +13,16 @@ import { Input } from "@components/Input";
|
|||||||
import { Label } from "@components/Label";
|
import { Label } from "@components/Label";
|
||||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||||
import ModalHeader from "@components/modal/ModalHeader";
|
import ModalHeader from "@components/modal/ModalHeader";
|
||||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
|
||||||
import { SelectDropdown } from "@components/select/SelectDropdown";
|
import { SelectDropdown } from "@components/select/SelectDropdown";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
|
||||||
import useFetchApi from "@utils/api";
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ClockFadingIcon,
|
ClockFadingIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
|
||||||
ShieldXIcon,
|
ShieldXIcon,
|
||||||
Text,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Callout } from "@components/Callout";
|
import { Callout } from "@components/Callout";
|
||||||
import cidr from "ip-cidr";
|
|
||||||
import React, { useMemo, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||||
import { Peer } from "@/interfaces/Peer";
|
import { Peer } from "@/interfaces/Peer";
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
ReverseProxyTarget,
|
ReverseProxyTarget,
|
||||||
ReverseProxyTargetProtocol,
|
ReverseProxyTargetProtocol,
|
||||||
ReverseProxyTargetType,
|
ReverseProxyTargetType,
|
||||||
|
ServiceMode,
|
||||||
ServiceTargetOptionsPathRewrite,
|
ServiceTargetOptionsPathRewrite,
|
||||||
} from "@/interfaces/ReverseProxy";
|
} from "@/interfaces/ReverseProxy";
|
||||||
import {
|
import {
|
||||||
@@ -40,11 +41,18 @@ import {
|
|||||||
} from "@/contexts/ReverseProxiesProvider";
|
} from "@/contexts/ReverseProxiesProvider";
|
||||||
import { cn } from "@utils/helpers";
|
import { cn } from "@utils/helpers";
|
||||||
import { HelpTooltip } from "@components/HelpTooltip";
|
import { HelpTooltip } from "@components/HelpTooltip";
|
||||||
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
|
import InlineLink from "@components/InlineLink";
|
||||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
|
||||||
import Paragraph from "@components/Paragraph";
|
import Paragraph from "@components/Paragraph";
|
||||||
import ReverseProxyTargetCustomHeaders from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
|
import ReverseProxyTargetCustomHeaders from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
|
||||||
|
import ReverseProxyTargetSelector, {
|
||||||
|
Target,
|
||||||
|
} from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
|
||||||
import { useReverseProxyTargetOptions } from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
|
import { useReverseProxyTargetOptions } from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
|
||||||
|
import ReverseProxyAddressInput, {
|
||||||
|
CidrHelpText,
|
||||||
|
useReverseProxyAddress,
|
||||||
|
} from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
|
||||||
|
import Separator from "@components/Separator";
|
||||||
|
|
||||||
/** Get initial host value based on target, resource, or peer */
|
/** Get initial host value based on target, resource, or peer */
|
||||||
function getInitialHost(
|
function getInitialHost(
|
||||||
@@ -85,38 +93,33 @@ export default function ReverseProxyTargetModal({
|
|||||||
}: Readonly<Props>) {
|
}: Readonly<Props>) {
|
||||||
const existingTargets = reverseProxy.targets || [];
|
const existingTargets = reverseProxy.targets || [];
|
||||||
const domain = reverseProxy.domain;
|
const domain = reverseProxy.domain;
|
||||||
// Fetch resources and peers for target selection
|
|
||||||
const { data: resources } = useFetchApi<NetworkResource[]>(
|
|
||||||
"/networks/resources",
|
|
||||||
);
|
|
||||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
|
||||||
|
|
||||||
const [tab, setTab] = useState("details");
|
const [target, setTarget] = useState<Target | undefined>(
|
||||||
|
currentTarget || initialResource || initialPeer
|
||||||
|
? {
|
||||||
|
type:
|
||||||
|
currentTarget?.target_type ??
|
||||||
|
(initialResource
|
||||||
|
? (initialResource.type as ReverseProxyTargetType) ??
|
||||||
|
ReverseProxyTargetType.HOST
|
||||||
|
: ReverseProxyTargetType.PEER),
|
||||||
|
peerId:
|
||||||
|
currentTarget?.target_type === ReverseProxyTargetType.PEER
|
||||||
|
? currentTarget?.target_id
|
||||||
|
: initialPeer?.id,
|
||||||
|
resourceId:
|
||||||
|
currentTarget && isResourceTargetType(currentTarget.target_type)
|
||||||
|
? currentTarget?.target_id
|
||||||
|
: initialResource?.id,
|
||||||
|
host: getInitialHost(currentTarget, initialResource, initialPeer),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const [targetType, setTargetType] = useState<ReverseProxyTargetType>(
|
|
||||||
currentTarget?.target_type ??
|
|
||||||
(initialResource
|
|
||||||
? (initialResource.type as ReverseProxyTargetType) ??
|
|
||||||
ReverseProxyTargetType.HOST
|
|
||||||
: ReverseProxyTargetType.PEER),
|
|
||||||
);
|
|
||||||
const [targetPeerId, setTargetPeerId] = useState<string | undefined>(
|
|
||||||
currentTarget?.target_type === ReverseProxyTargetType.PEER
|
|
||||||
? currentTarget?.target_id
|
|
||||||
: initialPeer?.id,
|
|
||||||
);
|
|
||||||
const [targetResourceId, setTargetResourceId] = useState<string | undefined>(
|
|
||||||
currentTarget && isResourceTargetType(currentTarget.target_type)
|
|
||||||
? currentTarget?.target_id
|
|
||||||
: initialResource?.id,
|
|
||||||
);
|
|
||||||
const [targetProtocol, setTargetProtocol] =
|
const [targetProtocol, setTargetProtocol] =
|
||||||
useState<ReverseProxyTargetProtocol>(
|
useState<ReverseProxyTargetProtocol>(
|
||||||
currentTarget?.protocol ?? ReverseProxyTargetProtocol.HTTP,
|
currentTarget?.protocol ?? ReverseProxyTargetProtocol.HTTP,
|
||||||
);
|
);
|
||||||
const [targetHost, setTargetHost] = useState(
|
|
||||||
getInitialHost(currentTarget, initialResource, initialPeer),
|
|
||||||
);
|
|
||||||
const [targetPort, setTargetPort] = useState<number>(
|
const [targetPort, setTargetPort] = useState<number>(
|
||||||
currentTarget?.port ?? 0,
|
currentTarget?.port ?? 0,
|
||||||
);
|
);
|
||||||
@@ -125,50 +128,9 @@ export default function ReverseProxyTargetModal({
|
|||||||
const [options, setOption, { getTargetOptions, headers, errors }] =
|
const [options, setOption, { getTargetOptions, headers, errors }] =
|
||||||
useReverseProxyTargetOptions(currentTarget?.options);
|
useReverseProxyTargetOptions(currentTarget?.options);
|
||||||
const portInputRef = useRef<HTMLInputElement>(null);
|
const portInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [installModal, setInstallModal] = useState(false);
|
|
||||||
|
|
||||||
// Get the current resource's address (from initialResource or selected resource)
|
const { isCidrRange, isHostEditable, isValidCidrHost } =
|
||||||
const currentResourceAddress = useMemo(() => {
|
useReverseProxyAddress(target);
|
||||||
if (initialResource) return initialResource.address;
|
|
||||||
if (targetResourceId) {
|
|
||||||
const resource = resources?.find((r) => r.id === targetResourceId);
|
|
||||||
return resource?.address || "";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}, [initialResource, targetResourceId, resources]);
|
|
||||||
|
|
||||||
// Parse the CIDR using ip-cidr library
|
|
||||||
const cidrInfo = useMemo(() => {
|
|
||||||
if (!currentResourceAddress) return null;
|
|
||||||
if (!cidr.isValidCIDR(currentResourceAddress)) return null;
|
|
||||||
try {
|
|
||||||
return new cidr(currentResourceAddress);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [currentResourceAddress]);
|
|
||||||
|
|
||||||
// Get the CIDR mask (e.g., 24 for /24)
|
|
||||||
const cidrMask = useMemo(() => {
|
|
||||||
if (!cidrInfo) return null;
|
|
||||||
const parts = currentResourceAddress.split("/");
|
|
||||||
return parts.length === 2 ? parseInt(parts[1], 10) : 32;
|
|
||||||
}, [cidrInfo, currentResourceAddress]);
|
|
||||||
|
|
||||||
// Check if address is a CIDR range (has more than one address)
|
|
||||||
const isCidrRange = useMemo(() => {
|
|
||||||
return cidrMask !== null && cidrMask < 32;
|
|
||||||
}, [cidrMask]);
|
|
||||||
|
|
||||||
// Host should be editable if it's a CIDR range with multiple addresses
|
|
||||||
const isHostEditable = isCidrRange;
|
|
||||||
|
|
||||||
// Validate if current host is within CIDR range
|
|
||||||
const isHostInCidrRange = useMemo(() => {
|
|
||||||
if (!cidrInfo || !targetHost) return false;
|
|
||||||
if (!cidr.isValidAddress(targetHost)) return false;
|
|
||||||
return cidrInfo.contains(targetHost);
|
|
||||||
}, [cidrInfo, targetHost]);
|
|
||||||
|
|
||||||
// Normalize path for comparison (ensure it starts with / and handle empty as /)
|
// Normalize path for comparison (ensure it starts with / and handle empty as /)
|
||||||
const normalizePath = (path: string | undefined) => {
|
const normalizePath = (path: string | undefined) => {
|
||||||
@@ -196,7 +158,6 @@ export default function ReverseProxyTargetModal({
|
|||||||
|
|
||||||
const isValidPort =
|
const isValidPort =
|
||||||
targetPort === 0 || (targetPort >= 1 && targetPort <= 65535);
|
targetPort === 0 || (targetPort >= 1 && targetPort <= 65535);
|
||||||
const isValidCidrHost = !isCidrRange || (targetHost && isHostInCidrRange);
|
|
||||||
|
|
||||||
const canAddTarget = useMemo(() => {
|
const canAddTarget = useMemo(() => {
|
||||||
// Don't allow if path is duplicate or port is invalid
|
// Don't allow if path is duplicate or port is invalid
|
||||||
@@ -209,11 +170,12 @@ export default function ReverseProxyTargetModal({
|
|||||||
if (initialPeer) {
|
if (initialPeer) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (targetType === ReverseProxyTargetType.PEER) {
|
if (!target) return false;
|
||||||
return !!targetPeerId;
|
if (target.type === ReverseProxyTargetType.PEER) {
|
||||||
|
return !!target.peerId;
|
||||||
}
|
}
|
||||||
if (isResourceTargetType(targetType)) {
|
if (isResourceTargetType(target.type)) {
|
||||||
return !!targetResourceId && isValidCidrHost;
|
return !!target.resourceId && isValidCidrHost;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}, [
|
}, [
|
||||||
@@ -221,28 +183,30 @@ export default function ReverseProxyTargetModal({
|
|||||||
isValidPort,
|
isValidPort,
|
||||||
initialResource,
|
initialResource,
|
||||||
initialPeer,
|
initialPeer,
|
||||||
targetType,
|
target,
|
||||||
targetPeerId,
|
|
||||||
targetResourceId,
|
|
||||||
isValidCidrHost,
|
isValidCidrHost,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasTarget =
|
const hasTarget = !!(initialResource || initialPeer || target);
|
||||||
initialResource || initialPeer || targetPeerId || targetResourceId;
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const resolvedType = initialPeer ? ReverseProxyTargetType.PEER : targetType;
|
if (!target) return;
|
||||||
|
const resolvedType = initialPeer
|
||||||
|
? ReverseProxyTargetType.PEER
|
||||||
|
: target.type;
|
||||||
const resolvedIsResource =
|
const resolvedIsResource =
|
||||||
isResourceTargetType(resolvedType) || !!initialResource;
|
isResourceTargetType(resolvedType) || !!initialResource;
|
||||||
const targetData: ReverseProxyTarget = {
|
const targetData: ReverseProxyTarget = {
|
||||||
target_type: resolvedType,
|
target_type: resolvedType,
|
||||||
target_id:
|
target_id:
|
||||||
resolvedType === ReverseProxyTargetType.PEER
|
resolvedType === ReverseProxyTargetType.PEER
|
||||||
? targetPeerId
|
? target.peerId
|
||||||
: targetResourceId,
|
: target.resourceId,
|
||||||
protocol: targetProtocol,
|
protocol: targetProtocol,
|
||||||
host:
|
host:
|
||||||
resolvedType === ReverseProxyTargetType.SUBNET ? targetHost : undefined,
|
resolvedType === ReverseProxyTargetType.SUBNET
|
||||||
|
? target.host
|
||||||
|
: undefined,
|
||||||
port: targetPort,
|
port: targetPort,
|
||||||
path: targetPath || undefined,
|
path: targetPath || undefined,
|
||||||
enabled: currentTarget?.enabled ?? true,
|
enabled: currentTarget?.enabled ?? true,
|
||||||
@@ -264,397 +228,291 @@ export default function ReverseProxyTargetModal({
|
|||||||
color="netbird"
|
color="netbird"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
<Separator />
|
||||||
<TabsList justify={"start"} className={"px-8"}>
|
|
||||||
<TabsTrigger value={"details"}>
|
|
||||||
<Text size={14} />
|
|
||||||
Details
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value={"options"} disabled={!canAddTarget}>
|
|
||||||
<Settings size={14} />
|
|
||||||
Advanced Settings
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value={"details"} className={"pb-8"}>
|
<div className="px-8 pt-5 pb-4 flex flex-col gap-6">
|
||||||
<div className="px-8 flex flex-col gap-8">
|
{!initialResource && !initialPeer && (
|
||||||
{!initialResource && !initialPeer && (
|
<ReverseProxyTargetSelector
|
||||||
<div>
|
value={target}
|
||||||
<Label className={"gap-0 inline"}>
|
initialNetwork={initialNetwork}
|
||||||
{initialNetwork ? (
|
onChange={(selection) => {
|
||||||
"Select Resource"
|
setTarget(selection);
|
||||||
) : (
|
if (selection) {
|
||||||
<>
|
setTimeout(() => portInputRef.current?.focus(), 0);
|
||||||
Select{" "}
|
}
|
||||||
<HelpTooltip
|
}}
|
||||||
className={"max-w-sm"}
|
/>
|
||||||
content={
|
)}
|
||||||
<>
|
|
||||||
A{" "}
|
|
||||||
<span className={"text-white font-medium"}>
|
|
||||||
peer
|
|
||||||
</span>{" "}
|
|
||||||
is a machine (e.g., laptop, server, container)
|
|
||||||
running NetBird. Select a peer if your service
|
|
||||||
runs directly on it.
|
|
||||||
<span className={"mt-1 block"}>
|
|
||||||
If you don't have a peer yet, you can{" "}
|
|
||||||
<InlineButtonLink
|
|
||||||
onClick={() => setInstallModal(true)}
|
|
||||||
>
|
|
||||||
Install NetBird
|
|
||||||
</InlineButtonLink>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
interactive={true}
|
|
||||||
>
|
|
||||||
Peer
|
|
||||||
</HelpTooltip>{" "}
|
|
||||||
or{" "}
|
|
||||||
<HelpTooltip
|
|
||||||
className={"max-w-sm"}
|
|
||||||
content={
|
|
||||||
<>
|
|
||||||
A{" "}
|
|
||||||
<span className={"text-white font-medium"}>
|
|
||||||
resource
|
|
||||||
</span>{" "}
|
|
||||||
is a destination (IP, subnet, or domain) that
|
|
||||||
can't run NetBird directly. Resources are
|
|
||||||
part of a network and are reached through a
|
|
||||||
routing peer that forwards traffic to them.
|
|
||||||
<span className={"mt-1 block"}>
|
|
||||||
If you don't have resources yet, go to{" "}
|
|
||||||
<InlineLink href={"/networks"}>
|
|
||||||
Networks
|
|
||||||
</InlineLink>{" "}
|
|
||||||
to create some.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
interactive={true}
|
|
||||||
>
|
|
||||||
Resource
|
|
||||||
</HelpTooltip>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<HelpText>
|
<div>
|
||||||
{initialNetwork
|
<Label>Location (Optional)</Label>
|
||||||
? "Select the resource from your network you want to expose."
|
<HelpText>
|
||||||
: "Select the peer where your service is running or select a resource to expose it."}
|
Specify an optional path from where requests are routed to your
|
||||||
</HelpText>
|
service.
|
||||||
<PeerGroupSelector
|
</HelpText>
|
||||||
values={[]}
|
<div className="flex w-full">
|
||||||
onChange={() => {}}
|
<div
|
||||||
placeholder={
|
className={`bg-nb-gray-900 rounded-l-md border text-nb-gray-300 border-r-0 text-sm border-nb-gray-700 flex items-center justify-center whitespace-nowrap px-4 ${
|
||||||
initialNetwork
|
!hasTarget ? "opacity-50" : "opacity-80"
|
||||||
? "Select a resource..."
|
}`}
|
||||||
: "Select a peer or resource..."
|
>
|
||||||
}
|
{domain || "domain.example.com"}
|
||||||
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={
|
|
||||||
isResourceTargetType(targetType) && targetResourceId
|
|
||||||
? { id: targetResourceId, type: "host" }
|
|
||||||
: targetType === ReverseProxyTargetType.PEER &&
|
|
||||||
targetPeerId
|
|
||||||
? { id: targetPeerId, type: "peer" }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onResourceChange={(res) => {
|
|
||||||
if (res) {
|
|
||||||
if (res.type === "peer") {
|
|
||||||
setTargetType(ReverseProxyTargetType.PEER);
|
|
||||||
setTargetPeerId(res.id);
|
|
||||||
setTargetResourceId(undefined);
|
|
||||||
const peer = peers?.find((p) => p.id === res.id);
|
|
||||||
setTargetHost(peer?.ip || "localhost");
|
|
||||||
} else {
|
|
||||||
const selectedResource = resources?.find(
|
|
||||||
(r) => r.id === res.id,
|
|
||||||
);
|
|
||||||
setTargetType(
|
|
||||||
(selectedResource?.type as ReverseProxyTargetType) ??
|
|
||||||
ReverseProxyTargetType.HOST,
|
|
||||||
);
|
|
||||||
setTargetResourceId(res.id);
|
|
||||||
setTargetPeerId(undefined);
|
|
||||||
const address = selectedResource?.address || "";
|
|
||||||
// If CIDR range, pre-fill with base IP
|
|
||||||
if (address.includes("/")) {
|
|
||||||
setTargetHost(address.split("/")[0]);
|
|
||||||
} else {
|
|
||||||
setTargetHost(address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTimeout(() => portInputRef.current?.focus(), 0);
|
|
||||||
} else {
|
|
||||||
setTargetPeerId(undefined);
|
|
||||||
setTargetResourceId(undefined);
|
|
||||||
setTargetHost("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>Location (Optional)</Label>
|
|
||||||
<HelpText>
|
|
||||||
Specify an optional path from where requests are routed to
|
|
||||||
your service.
|
|
||||||
</HelpText>
|
|
||||||
<div className="flex w-full">
|
|
||||||
<div
|
|
||||||
className={`bg-nb-gray-900 rounded-l-md border text-nb-gray-300 border-r-0 text-sm border-nb-gray-700 flex items-center justify-center whitespace-nowrap px-4 ${
|
|
||||||
!hasTarget ? "opacity-50" : "opacity-80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{domain || "domain.example.com"}
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
placeholder="/"
|
|
||||||
value={targetPath}
|
|
||||||
className={cn("rounded-l-none")}
|
|
||||||
maxWidthClass="w-full"
|
|
||||||
disabled={!hasTarget}
|
|
||||||
onChange={(e) => {
|
|
||||||
let value = e.target.value;
|
|
||||||
if (value && !value.startsWith("/")) {
|
|
||||||
value = "/" + value;
|
|
||||||
}
|
|
||||||
setTargetPath(value);
|
|
||||||
if (!value || value === "/") {
|
|
||||||
setOption("path_rewrite", undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isPathDuplicate && hasTarget && (
|
|
||||||
<Callout
|
|
||||||
variant="warning"
|
|
||||||
className="mt-3"
|
|
||||||
icon={
|
|
||||||
<AlertTriangle
|
|
||||||
size={14}
|
|
||||||
className="shrink-0 relative top-[3px]"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
This location is already used by another target and cannot
|
|
||||||
be added. <br /> Please use a different location.
|
|
||||||
</Callout>
|
|
||||||
)}
|
|
||||||
{targetPath &&
|
|
||||||
targetPath !== "/" &&
|
|
||||||
hasTarget &&
|
|
||||||
!isPathDuplicate && (
|
|
||||||
<FancyToggleSwitch
|
|
||||||
value={options.path_rewrite === "preserve"}
|
|
||||||
onChange={(v) =>
|
|
||||||
setOption(
|
|
||||||
"path_rewrite",
|
|
||||||
v
|
|
||||||
? ("preserve" as ServiceTargetOptionsPathRewrite)
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={"mt-3.5"}
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
Preserve Full Path
|
|
||||||
<HelpTooltip
|
|
||||||
content={
|
|
||||||
<div className="text-xs max-w-xs flex flex-col gap-2">
|
|
||||||
<div>
|
|
||||||
When disabled, a request to e.g.,{" "}
|
|
||||||
<span className="font-mono text-white">
|
|
||||||
{targetPath}/users
|
|
||||||
</span>{" "}
|
|
||||||
is forwarded as{" "}
|
|
||||||
<span className="font-mono text-white">
|
|
||||||
/users
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
When enabled, a request to e.g.,{" "}
|
|
||||||
<span className="font-mono text-white">
|
|
||||||
{targetPath}/users
|
|
||||||
</span>{" "}
|
|
||||||
is forwarded as{" "}
|
|
||||||
<span className="font-mono text-white">
|
|
||||||
{targetPath}/users
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
helpText={
|
|
||||||
<div>
|
|
||||||
Keep the original full request path when forwarding.{" "}
|
|
||||||
<br />
|
|
||||||
When disabled the matched prefix path is stripped.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Input
|
||||||
<div>
|
placeholder="/"
|
||||||
<div className="flex gap-3 mt-1">
|
value={targetPath}
|
||||||
<div className="flex-1">
|
className={cn("rounded-l-none")}
|
||||||
<Label>Protocol & Host / IP</Label>
|
maxWidthClass="w-full"
|
||||||
{cidrInfo && (
|
disabled={!hasTarget}
|
||||||
<HelpText className="!mt-1">
|
onChange={(e) => {
|
||||||
Enter an IP address within {currentResourceAddress}
|
let value = e.target.value;
|
||||||
</HelpText>
|
if (value && !value.startsWith("/")) {
|
||||||
)}
|
value = "/" + value;
|
||||||
<div className="flex items-center mt-2">
|
}
|
||||||
<div className="w-[120px]">
|
setTargetPath(value);
|
||||||
<SelectDropdown
|
if (!value || value === "/") {
|
||||||
value={targetProtocol}
|
setOption("path_rewrite", undefined);
|
||||||
onChange={(v) => {
|
}
|
||||||
const proto = v as ReverseProxyTargetProtocol;
|
}}
|
||||||
setTargetProtocol(proto);
|
/>
|
||||||
if (proto !== ReverseProxyTargetProtocol.HTTPS) {
|
</div>
|
||||||
setOption("skip_tls_verify", undefined);
|
{isPathDuplicate && hasTarget && (
|
||||||
}
|
<Callout
|
||||||
}}
|
variant="warning"
|
||||||
options={[
|
className="mt-3"
|
||||||
{
|
icon={
|
||||||
value: ReverseProxyTargetProtocol.HTTP,
|
<AlertTriangle
|
||||||
label: "http://",
|
size={14}
|
||||||
},
|
className="shrink-0 relative top-[3px]"
|
||||||
{
|
/>
|
||||||
value: ReverseProxyTargetProtocol.HTTPS,
|
}
|
||||||
label: "https://",
|
>
|
||||||
},
|
This location is already used by another target and cannot be
|
||||||
]}
|
added. <br /> Please use a different location.
|
||||||
className="!rounded-r-none !border-r-0"
|
</Callout>
|
||||||
disabled={!hasTarget}
|
)}
|
||||||
/>
|
{targetPath &&
|
||||||
</div>
|
targetPath !== "/" &&
|
||||||
<div className="flex-1">
|
hasTarget &&
|
||||||
<Input
|
!isPathDuplicate && (
|
||||||
value={targetHost}
|
<FancyToggleSwitch
|
||||||
onChange={(e) => {
|
value={options.path_rewrite === "preserve"}
|
||||||
// Only allow valid IP characters for CIDR ranges
|
onChange={(v) =>
|
||||||
const value = isHostEditable
|
setOption(
|
||||||
? e.target.value.replace(/[^0-9.]/g, "")
|
"path_rewrite",
|
||||||
: e.target.value;
|
v
|
||||||
setTargetHost(value);
|
? ("preserve" as ServiceTargetOptionsPathRewrite)
|
||||||
}}
|
: undefined,
|
||||||
placeholder="e.g., 192.168.0.10"
|
)
|
||||||
className="!rounded-l-none"
|
}
|
||||||
disabled={!hasTarget}
|
className={"mt-3.5"}
|
||||||
readOnly={
|
label={
|
||||||
hasTarget && !isHostEditable ? true : undefined
|
<>
|
||||||
}
|
Preserve Full Path
|
||||||
autoFocus={!!initialResource && isHostEditable}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-[150px]">
|
|
||||||
<Label>
|
|
||||||
Port
|
|
||||||
<HelpTooltip
|
<HelpTooltip
|
||||||
content={
|
content={
|
||||||
"Enter the port where your service (e.g., webserver, app, API) is currently listening. If left empty, defaults to port 80 for HTTP or 443 for HTTPS."
|
<div className="text-xs max-w-xs flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
When disabled, a request to e.g.,{" "}
|
||||||
|
<span className="font-mono text-white">
|
||||||
|
{targetPath}/users
|
||||||
|
</span>{" "}
|
||||||
|
is forwarded as{" "}
|
||||||
|
<span className="font-mono text-white">
|
||||||
|
/users
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
When enabled, a request to e.g.,{" "}
|
||||||
|
<span className="font-mono text-white">
|
||||||
|
{targetPath}/users
|
||||||
|
</span>{" "}
|
||||||
|
is forwarded as{" "}
|
||||||
|
<span className="font-mono text-white">
|
||||||
|
{targetPath}/users
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Label>
|
</>
|
||||||
{cidrInfo && (
|
}
|
||||||
<HelpText className="!mt-1"> </HelpText>
|
helpText={
|
||||||
)}
|
<div>
|
||||||
<div className="mt-2">
|
Keep the original full request path when forwarding.{" "}
|
||||||
<Input
|
<br />
|
||||||
ref={portInputRef}
|
When disabled the matched prefix path is stripped.
|
||||||
type="number"
|
|
||||||
value={targetPort === 0 ? "" : targetPort}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTargetPort(parseInt(e.target.value) || 0)
|
|
||||||
}
|
|
||||||
placeholder={String(
|
|
||||||
defaultPortForProtocol(targetProtocol),
|
|
||||||
)}
|
|
||||||
min={0}
|
|
||||||
max={65535}
|
|
||||||
disabled={!hasTarget}
|
|
||||||
autoFocus={!!initialResource && !isHostEditable}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex mt-1">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>
|
||||||
|
Protocol & Host / IP
|
||||||
|
<CidrHelpText target={target} />
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center mt-2">
|
||||||
|
<div className="w-[120px]">
|
||||||
|
<SelectDropdown
|
||||||
|
value={targetProtocol}
|
||||||
|
onChange={(v) => {
|
||||||
|
const proto = v as ReverseProxyTargetProtocol;
|
||||||
|
setTargetProtocol(proto);
|
||||||
|
if (proto !== ReverseProxyTargetProtocol.HTTPS) {
|
||||||
|
setOption("skip_tls_verify", undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: ReverseProxyTargetProtocol.HTTP,
|
||||||
|
label: "http://",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ReverseProxyTargetProtocol.HTTPS,
|
||||||
|
label: "https://",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
className="!rounded-r-none !border-r-0"
|
||||||
|
disabled={!hasTarget}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<ReverseProxyAddressInput
|
||||||
|
value={target}
|
||||||
|
onChange={setTarget}
|
||||||
|
autoFocus={!!initialResource && isHostEditable}
|
||||||
|
className="!rounded-l-none"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{targetProtocol === ReverseProxyTargetProtocol.HTTPS &&
|
|
||||||
hasTarget && (
|
|
||||||
<FancyToggleSwitch
|
|
||||||
className={"mt-3.5"}
|
|
||||||
value={options.skip_tls_verify ?? false}
|
|
||||||
onChange={(v) =>
|
|
||||||
setOption("skip_tls_verify", v || undefined)
|
|
||||||
}
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
<ShieldXIcon size={15} />
|
|
||||||
Skip TLS Verification
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
helpText="Skip certificate verification when connecting to this target. Useful if your service already uses a self-signed certificate."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="w-[150px]">
|
||||||
</TabsContent>
|
<Label>
|
||||||
|
Port
|
||||||
<TabsContent value={"options"} className={"pb-8"}>
|
<HelpTooltip
|
||||||
<div className="px-8 flex flex-col gap-8 pt-1.5">
|
content={
|
||||||
<div className={"flex items-center justify-between"}>
|
"Enter the port where your service (e.g., webserver, app, API) is currently listening. If left empty, defaults to port 80 for HTTP or 443 for HTTPS."
|
||||||
<div>
|
}
|
||||||
<Label>Request Timeout</Label>
|
/>
|
||||||
<HelpText className={"mb-0"}>
|
</Label>
|
||||||
Max time to wait for a response as duration string (max
|
<div className="mt-2">
|
||||||
5m). <br /> Leave this field empty for no timeout.
|
<Input
|
||||||
</HelpText>
|
ref={portInputRef}
|
||||||
|
type="number"
|
||||||
|
className={"rounded-l-none"}
|
||||||
|
value={targetPort === 0 ? "" : targetPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTargetPort(parseInt(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
placeholder={String(
|
||||||
|
defaultPortForProtocol(targetProtocol),
|
||||||
|
)}
|
||||||
|
min={0}
|
||||||
|
max={65535}
|
||||||
|
disabled={!hasTarget}
|
||||||
|
autoFocus={!!initialResource && !isHostEditable}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
|
||||||
customPrefix={<ClockFadingIcon size={16} />}
|
|
||||||
placeholder="e.g. 10s, 30s, 1m"
|
|
||||||
value={options.request_timeout ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setOption("request_timeout", e.target.value || undefined)
|
|
||||||
}
|
|
||||||
maxWidthClass="w-[180px]"
|
|
||||||
errorTooltip={true}
|
|
||||||
error={errors.timeout}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ReverseProxyTargetCustomHeaders {...headers} />
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
{targetProtocol === ReverseProxyTargetProtocol.HTTPS &&
|
||||||
</Tabs>
|
hasTarget && (
|
||||||
|
<FancyToggleSwitch
|
||||||
|
className={"mt-3.5"}
|
||||||
|
value={options.skip_tls_verify ?? false}
|
||||||
|
onChange={(v) =>
|
||||||
|
setOption("skip_tls_verify", v || undefined)
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<ShieldXIcon size={15} />
|
||||||
|
Skip TLS Verification
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
helpText="Skip certificate verification when connecting to this target. Useful if your service already uses a self-signed certificate."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Accordion
|
||||||
|
type={"multiple"}
|
||||||
|
className={"flex flex-col gap-2 -mt-2"}
|
||||||
|
>
|
||||||
|
<AccordionItem value={"optional-settings"}>
|
||||||
|
<AccordionTrigger
|
||||||
|
className={
|
||||||
|
"text-[0.8rem] tracking-wider text-nb-gray-200 py-4 my-0 leading-none gap-2 flex items-center"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={"relative top-[1px]"}>
|
||||||
|
Optional Settings
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className={"flex flex-col gap-8 pb-6 pt-2"}>
|
||||||
|
<div className={"flex items-center justify-between"}>
|
||||||
|
<div>
|
||||||
|
<Label>Request Timeout</Label>
|
||||||
|
<HelpText className={"mb-0"}>
|
||||||
|
Max time to wait for a response as duration string
|
||||||
|
(max 5m). <br /> Leave this field empty for no
|
||||||
|
timeout.
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
customPrefix={<ClockFadingIcon size={16} />}
|
||||||
|
placeholder="e.g. 10s, 30s, 1m"
|
||||||
|
value={options.request_timeout ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOption(
|
||||||
|
"request_timeout",
|
||||||
|
e.target.value || undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
maxWidthClass="w-[180px]"
|
||||||
|
errorTooltip={true}
|
||||||
|
error={errors.timeout}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reverseProxy.mode === ServiceMode.UDP && (
|
||||||
|
<div className={"flex items-center justify-between"}>
|
||||||
|
<div>
|
||||||
|
<Label>Session Idle Timeout</Label>
|
||||||
|
<HelpText className={"mb-0"}>
|
||||||
|
How long a UDP session stays alive without traffic
|
||||||
|
(max 10m). <br /> Defaults to 30s when empty.
|
||||||
|
</HelpText>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
customPrefix={<ClockFadingIcon size={16} />}
|
||||||
|
placeholder="e.g. 30s, 2m, 5m"
|
||||||
|
value={options.session_idle_timeout ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOption(
|
||||||
|
"session_idle_timeout",
|
||||||
|
e.target.value || undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
maxWidthClass="w-[180px]"
|
||||||
|
errorTooltip={true}
|
||||||
|
error={errors.sessionIdleTimeout}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReverseProxyTargetCustomHeaders {...headers} />
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ModalFooter className={"items-center"}>
|
<ModalFooter className={"items-center"}>
|
||||||
<div className={"w-full"}>
|
<div className={"w-full"}>
|
||||||
@@ -670,69 +528,27 @@ export default function ReverseProxyTargetModal({
|
|||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 w-full justify-end">
|
<div className="flex gap-3 w-full justify-end">
|
||||||
{currentTarget ? (
|
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
<>
|
Cancel
|
||||||
<Button
|
</Button>
|
||||||
variant="secondary"
|
<Button
|
||||||
onClick={() => onOpenChange(false)}
|
variant="primary"
|
||||||
>
|
onClick={handleSave}
|
||||||
Cancel
|
disabled={!canAddTarget || errors.options}
|
||||||
</Button>
|
>
|
||||||
<Button
|
{currentTarget ? (
|
||||||
variant="primary"
|
"Save Changes"
|
||||||
onClick={handleSave}
|
) : (
|
||||||
disabled={!canAddTarget || errors.options}
|
<>
|
||||||
>
|
<PlusCircle size={16} />
|
||||||
Save Changes
|
Add Target
|
||||||
</Button>
|
</>
|
||||||
</>
|
)}
|
||||||
) : (
|
</Button>
|
||||||
<>
|
|
||||||
{tab === "details" && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => setTab("options")}
|
|
||||||
disabled={!canAddTarget}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tab === "options" && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setTab("details")}
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!canAddTarget || errors.options}
|
|
||||||
>
|
|
||||||
<PlusCircle size={16} />
|
|
||||||
Add Target
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={installModal} onOpenChange={setInstallModal}>
|
|
||||||
<SetupModal />
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx
Normal file
151
src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx
Normal file
@@ -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<Props>) {
|
||||||
|
const { resources, peers } = useReverseProxies();
|
||||||
|
const [installModal, setInstallModal] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label className={"gap-0 inline"}>
|
||||||
|
{initialNetwork ? (
|
||||||
|
"Select Resource"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Select{" "}
|
||||||
|
<HelpTooltip
|
||||||
|
className={"max-w-sm"}
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
A <span className={"text-white font-medium"}>peer</span> is a
|
||||||
|
machine (e.g., laptop, server, container) running NetBird.
|
||||||
|
Select a peer if your service runs directly on it.
|
||||||
|
<span className={"mt-1 block"}>
|
||||||
|
If you don't have a peer yet, you can{" "}
|
||||||
|
<InlineButtonLink onClick={() => setInstallModal(true)}>
|
||||||
|
Install NetBird
|
||||||
|
</InlineButtonLink>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
interactive={true}
|
||||||
|
>
|
||||||
|
Peer
|
||||||
|
</HelpTooltip>{" "}
|
||||||
|
or{" "}
|
||||||
|
<HelpTooltip
|
||||||
|
className={"max-w-sm"}
|
||||||
|
content={
|
||||||
|
<>
|
||||||
|
A <span className={"text-white font-medium"}>resource</span>{" "}
|
||||||
|
is a destination (IP, subnet, or domain) that can't run
|
||||||
|
NetBird directly. Resources are part of a network and are
|
||||||
|
reached through a routing peer that forwards traffic to them.
|
||||||
|
<span className={"mt-1 block"}>
|
||||||
|
If you don't have resources yet, go to{" "}
|
||||||
|
<InlineLink href={"/networks"}>Networks</InlineLink> to
|
||||||
|
create some.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
interactive={true}
|
||||||
|
>
|
||||||
|
Resource
|
||||||
|
</HelpTooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<HelpText>
|
||||||
|
{initialNetwork
|
||||||
|
? "Select the resource from your network you want to expose."
|
||||||
|
: "Select the peer or resource where your service is running."}
|
||||||
|
</HelpText>
|
||||||
|
<PeerGroupSelector
|
||||||
|
values={[]}
|
||||||
|
onChange={() => {}}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal open={installModal} onOpenChange={setInstallModal}>
|
||||||
|
<SetupModal />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -74,6 +74,7 @@ export default function ReverseProxyTargetsTable({ reverseProxy }: Props) {
|
|||||||
className={"bg-nb-gray-960 py-2"}
|
className={"bg-nb-gray-960 py-2"}
|
||||||
inset={true}
|
inset={true}
|
||||||
text={"Targets"}
|
text={"Targets"}
|
||||||
|
initialPageSize={reverseProxy?.targets?.length}
|
||||||
manualPagination={true}
|
manualPagination={true}
|
||||||
sorting={sorting}
|
sorting={sorting}
|
||||||
columnVisibility={{}}
|
columnVisibility={{}}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { MoreVertical, Settings, SquarePenIcon, Trash2 } from "lucide-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
import { ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
|
import { isL4Mode, ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
target: ReverseProxyFlatTarget;
|
target: ReverseProxyFlatTarget;
|
||||||
@@ -40,7 +40,11 @@ export default function ReverseProxyFlatTargetActionCell({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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}
|
disabled={!permission?.services?.update}
|
||||||
>
|
>
|
||||||
@@ -57,9 +61,9 @@ export default function ReverseProxyFlatTargetActionCell({
|
|||||||
}}
|
}}
|
||||||
disabled={!permission?.services?.update}
|
disabled={!permission?.services?.update}
|
||||||
>
|
>
|
||||||
<div className={"flex gap-3 items-center"}>
|
<div className={"flex gap-3 items-center pr-6"}>
|
||||||
<Settings size={14} className={"shrink-0"} />
|
<Settings size={14} className={"shrink-0"} />
|
||||||
Settings
|
Advanced Settings
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
|||||||
@@ -44,14 +44,15 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
|||||||
: `/${target.path}`
|
: `/${target.path}`
|
||||||
: "";
|
: "";
|
||||||
const fullUrl = `${target.proxy.domain}${path}`;
|
const fullUrl = `${target.proxy.domain}${path}`;
|
||||||
const disabled = target.enabled === false;
|
const disabled = !target.enabled;
|
||||||
const isEnabled = target.proxy.enabled && target.enabled !== false;
|
const isEnabled = target.proxy.enabled && target.enabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={disabled ? "opacity-40" : ""}>
|
<div className={disabled ? "opacity-40" : ""}>
|
||||||
<ReverseProxyNameCell
|
<ReverseProxyNameCell
|
||||||
domain={fullUrl}
|
domain={fullUrl}
|
||||||
enabled={isEnabled}
|
enabled={isEnabled}
|
||||||
|
reverseProxy={row.original.proxy}
|
||||||
showChevron={false}
|
showChevron={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +63,7 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
|||||||
accessorKey: "arrow",
|
accessorKey: "arrow",
|
||||||
header: "",
|
header: "",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<ReverseProxyArrowCell disabled={row.original.enabled === false} />
|
<ReverseProxyArrowCell disabled={!row.original.enabled} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -120,7 +121,13 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
|||||||
{
|
{
|
||||||
id: "searchString",
|
id: "searchString",
|
||||||
accessorFn: (row) => {
|
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("");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
// Go time.ParseDuration format: one or more {number}{unit} pairs
|
// Go time.ParseDuration format: one or more {number}{unit} pairs
|
||||||
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
|
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
|
||||||
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
|
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
|
||||||
|
const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m
|
||||||
|
|
||||||
function parseDurationMs(duration: string): number {
|
function parseDurationMs(duration: string): number {
|
||||||
const units: Record<string, number> = {
|
const units: Record<string, number> = {
|
||||||
@@ -28,7 +29,7 @@ function parseDurationMs(duration: string): number {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateTimeout(timeout: string): string | undefined {
|
export function validateTimeout(timeout: string): string | undefined {
|
||||||
if (!timeout) return undefined;
|
if (!timeout) return undefined;
|
||||||
if (!DURATION_RE.test(timeout))
|
if (!DURATION_RE.test(timeout))
|
||||||
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
|
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
|
||||||
@@ -37,6 +38,15 @@ function validateTimeout(timeout: string): string | undefined {
|
|||||||
return 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(
|
export function useReverseProxyTargetOptions(
|
||||||
initialOptions?: ServiceTargetOptions,
|
initialOptions?: ServiceTargetOptions,
|
||||||
) {
|
) {
|
||||||
@@ -67,7 +77,11 @@ export function useReverseProxyTargetOptions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const timeoutError = validateTimeout(targetOptions.request_timeout ?? "");
|
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 getTargetOptions = useCallback((): ServiceTargetOptions | undefined => {
|
||||||
const customHeaders = headerEntriesToRecord(headerEntries);
|
const customHeaders = headerEntriesToRecord(headerEntries);
|
||||||
@@ -94,6 +108,7 @@ export function useReverseProxyTargetOptions(
|
|||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
timeout: timeoutError,
|
timeout: timeoutError,
|
||||||
|
sessionIdleTimeout: sessionIdleTimeoutError,
|
||||||
options: hasOptionsErrors,
|
options: hasOptionsErrors,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -257,3 +257,16 @@ export const singularize = (
|
|||||||
}
|
}
|
||||||
return count + " " + word;
|
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`;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user