Compare commits

...

13 Commits

Author SHA1 Message Date
Eduard Gert
01330e0f58 Fix missing peer context in group network routes tab (#620)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-04-23 17:05:05 +02:00
Viktor Liu
e9ac1a1a23 Add CrowdSec IP reputation (#600)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-04-21 12:29:37 +02:00
raghvendra
b53802a5c5 fix: prevent storage clear and logout on failed account deletion (#611) 2026-04-13 09:07:14 +02:00
Eduard Gert
9addc18956 Fix reverse proxy mode selection (#606)
* Fix reverse proxy mode selection

* Fix isNetBirdHosted

* Fix activity description
2026-04-09 09:52:35 +02:00
shuuri-labs
9701e6503b Add new pull request template + enforce documentation acknowledgement… (#602)
* Add new pull request template + enforce documentation acknowledgement in new workflow

* fix docs-ack workflow: pass PR number via env and simplify checkbox validation
2026-04-02 21:39:38 +02:00
Eduard Gert
0841caecbb Fix dns zone domain validation and peers last seen sort (#595)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-25 17:50:22 +01:00
Eduard Gert
c7846760d1 Add reverse proxy auth headers (#593)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments

* Add auth header modal

* Remove password managers from auth headers

* fix unique id

* Remove gradient, fix button roundness

* update lucide, add additional event auth methods

* Clear existing header value on change
2026-03-25 14:31:36 +01:00
Viktor Liu
8c283b6ef9 Support optional subdomain for reverse proxy domains (#589) 2026-03-24 16:01:01 +01:00
Eduard Gert
34ae3b4da6 Add reverse proxy access rules (#592)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments
2026-03-24 16:00:31 +01:00
Viktor Liu
aff2365ef7 Add layer 4 protocol support to reverse proxy (#579)
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>
2026-03-18 17:43:00 +01:00
Zoltan Papp
bad057d415 [dashboard] feat: add auto_update_always toggle to client settings (#580)
* [dashboard] feat: add auto_update_always toggle to client settings

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

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

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

* Update src/modules/settings/ClientSettingsTab.tsx

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

---------

Co-authored-by: Eduard Gert <kontakt@eduardgert.de>
2026-03-16 15:35:44 +01:00
Misha Bragin
4d846e2c94 Improve text for optional resource setiings (#584) 2026-03-12 20:48:09 +01:00
Eduard Gert
15fb6e0b05 Refactor resource modal (#582)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-12 16:30:51 +01:00
82 changed files with 4525 additions and 1515 deletions

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
## Issue ticket number and link
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [ ] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__

105
.github/workflows/docs-ack.yml vendored Normal file
View File

@@ -0,0 +1,105 @@
name: Docs Acknowledgement
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
docs-ack:
name: Require docs PR URL or explicit "not needed"
runs-on: ubuntu-latest
steps:
- name: Read PR body
id: body
shell: bash
run: |
set -euo pipefail
BODY_B64=$(jq -r '.pull_request.body // "" | @base64' "$GITHUB_EVENT_PATH")
{
echo "body_b64=$BODY_B64"
} >> "$GITHUB_OUTPUT"
- name: Validate checkbox selection
id: validate
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
if ! body="$(printf '%s' "$BODY_B64" | base64 -d)"; then
echo "::error::Failed to decode PR body from base64. Data may be corrupted or missing."
exit 1
fi
added_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*I added/updated documentation' | wc -l | tr -d '[:space:]' || true)
noneed_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*Documentation is \*\*not needed\*\*' | wc -l | tr -d '[:space:]' || true)
total=$((added_checked + noneed_checked))
if [ "$total" -ne 1 ]; then
echo "::error::You must check exactly one docs option in the PR template (either 'docs added' OR 'not needed')."
exit 1
fi
if [ "$added_checked" -eq 1 ]; then
echo "mode=added" >> "$GITHUB_OUTPUT"
else
echo "mode=noneed" >> "$GITHUB_OUTPUT"
fi
- name: Extract docs PR URL (when 'docs added')
if: steps.validate.outputs.mode == 'added'
id: extract
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
body="$(printf '%s' "$BODY_B64" | base64 -d)"
# Strictly require HTTPS and that it's a PR in netbirdio/docs
# e.g., https://github.com/netbirdio/docs/pull/1234
url="$(printf '%s' "$body" | grep -Eo 'https://github\.com/netbirdio/docs/pull/[0-9]+' | head -n1 || true)"
if [ -z "${url:-}" ]; then
echo "::error::You checked 'docs added' but didn't include a valid HTTPS PR link to netbirdio/docs (e.g., https://github.com/netbirdio/docs/pull/1234)."
exit 1
fi
pr_number="$(printf '%s' "$url" | sed -E 's#.*/pull/([0-9]+)$#\1#')"
{
echo "url=$url"
echo "pr_number=$pr_number"
} >> "$GITHUB_OUTPUT"
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
uses: actions/github-script@v7
id: verify
env:
PR_NUMBER: ${{ steps.extract.outputs.pr_number }}
with:
script: |
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const { data } = await github.rest.pulls.get({
owner: 'netbirdio',
repo: 'docs',
pull_number: prNumber
});
// Allow open or merged PRs
const ok = data.state === 'open' || data.merged === true;
core.setOutput('state', data.state);
core.setOutput('merged', String(!!data.merged));
if (!ok) {
core.setFailed(`Docs PR #${prNumber} exists but is neither open nor merged (state=${data.state}, merged=${data.merged}).`);
}
result-encoding: string
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: All good
run: echo "Documentation requirement satisfied ✅"

184
package-lock.json generated
View File

@@ -59,8 +59,8 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"next": "^16.1.6",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
@@ -1213,9 +1213,9 @@
}
},
"node_modules/@next/env": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1229,9 +1229,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
"cpu": [
"arm64"
],
@@ -1245,9 +1245,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
"cpu": [
"x64"
],
@@ -1261,9 +1261,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
"cpu": [
"arm64"
],
@@ -1277,9 +1277,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
"cpu": [
"arm64"
],
@@ -1293,9 +1293,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
"cpu": [
"x64"
],
@@ -1309,9 +1309,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
"cpu": [
"x64"
],
@@ -1325,9 +1325,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
"cpu": [
"arm64"
],
@@ -1341,9 +1341,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
"cpu": [
"x64"
],
@@ -3244,32 +3244,6 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -3998,10 +3972,13 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
@@ -4025,13 +4002,15 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -4320,12 +4299,6 @@
"node": ">= 10"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -5768,9 +5741,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"license": "ISC"
},
"node_modules/for-each": {
@@ -6889,9 +6862,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -6923,9 +6896,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"version": "0.566.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.566.0.tgz",
"integrity": "sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -6973,15 +6946,18 @@
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "*"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
@@ -7067,14 +7043,14 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.1.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
"version": "16.1.7",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
"license": "MIT",
"dependencies": {
"@next/env": "16.1.6",
"@next/env": "16.1.7",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.8.3",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
@@ -7086,14 +7062,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.1.6",
"@next/swc-darwin-x64": "16.1.6",
"@next/swc-linux-arm64-gnu": "16.1.6",
"@next/swc-linux-arm64-musl": "16.1.6",
"@next/swc-linux-x64-gnu": "16.1.6",
"@next/swc-linux-x64-musl": "16.1.6",
"@next/swc-win32-arm64-msvc": "16.1.6",
"@next/swc-win32-x64-msvc": "16.1.6",
"@next/swc-darwin-arm64": "16.1.7",
"@next/swc-darwin-x64": "16.1.7",
"@next/swc-linux-arm64-gnu": "16.1.7",
"@next/swc-linux-arm64-musl": "16.1.7",
"@next/swc-linux-x64-gnu": "16.1.7",
"@next/swc-linux-x64-musl": "16.1.7",
"@next/swc-win32-arm64-msvc": "16.1.7",
"@next/swc-win32-x64-msvc": "16.1.7",
"sharp": "^0.34.4"
},
"peerDependencies": {
@@ -7412,9 +7388,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -8777,9 +8753,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {

View File

@@ -67,8 +67,8 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"next": "^16.1.6",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
@@ -89,6 +89,9 @@
"timescape": "^0.7.1",
"typescript": "^5"
},
"overrides": {
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@types/chroma-js": "^3.1.1",

View File

@@ -8,8 +8,12 @@ export default function ReverseProxyIcon(props: IconProps) {
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
{...iconProperties(props)}
fill={"currentColor"}
>
<path d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z" />
<path
fill={"currentColor"}
d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z"
/>
</svg>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -2,6 +2,7 @@
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { cn } from "@utils/helpers";
import { motion } from "framer-motion";
import { ChevronDown } from "lucide-react";
import * as React from "react";
@@ -23,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center gap-4 font-medium transition-all [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
"flex flex-1 items-center gap-4 font-medium [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
className,
)}
{...props}
@@ -36,20 +37,41 @@ const AccordionTrigger = React.forwardRef<
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
>
<div className=" pt-0">{children}</div>
</AccordionPrimitive.Content>
));
>(({ className, children }, ref) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
React.useEffect(() => {
const el = wrapperRef.current?.closest("[data-state]");
if (!el) return;
const update = () => setIsOpen(el.getAttribute("data-state") === "open");
update();
const observer = new MutationObserver(update);
observer.observe(el, { attributes: true, attributeFilter: ["data-state"] });
return () => observer.disconnect();
}, []);
return (
<div ref={wrapperRef}>
<motion.div
ref={ref}
initial={false}
animate={{
height: isOpen ? "auto" : 0,
opacity: isOpen ? 1 : 0,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
className={cn("overflow-hidden text-sm", className)}
>
<div className="pt-0">{children}</div>
</motion.div>
</div>
);
});
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@@ -23,6 +23,7 @@ const variants = cva("", {
purple: ["bg-purple-950/50 border-purple-500 border text-purple-500"],
yellow: ["bg-yellow-950 border-yellow-500 border text-yellow-400"],
gray: ["bg-nb-gray-930/60 border-nb-gray-800/40 text-nb-gray-300 border"],
lightGray: ["bg-nb-gray-910 text-nb-gray-200 border border-nb-gray-900"],
grayer: [
"bg-nb-gray-900/40 border-nb-gray-800/40 text-nb-gray-300 border",
],
@@ -45,6 +46,7 @@ const variants = cva("", {
"blue-darker": ["hover:bg-sky-800"],
red: ["hover:bg-red-950/40"],
gray: ["hover:bg-nb-gray-900"],
lightGray: ["hover:bg-nb-gray-900"],
grayer: ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
green: ["hover:bg-green-950/50"],

View File

@@ -74,7 +74,7 @@ export const buttonVariants = cva(
"",
],
"danger-text": [
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50",
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
],
"default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",

View File

@@ -80,13 +80,15 @@ export const DeviceCard = ({
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
{descriptionText && (
<span
className={
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
}
>
<TruncatedText text={descriptionText} maxWidth={"160px"} />
</span>
)}
</div>
</div>
);

View File

@@ -8,7 +8,7 @@ import React from "react";
export const fancyToggleSwitchVariants = cva([], {
variants: {
variant: {
default: ["px-6 py-4 border rounded-md"],
default: ["px-5 py-4 border rounded-md"],
blank: null,
},
state: {
@@ -45,6 +45,8 @@ interface Props extends FancyToggleSwitchVariants {
disabled?: boolean;
dataCy?: string;
className?: string;
labelClassName?: string;
textWrapperClassName?: string;
}
export default function FancyToggleSwitch({
@@ -57,6 +59,8 @@ export default function FancyToggleSwitch({
dataCy,
className,
variant = "default",
labelClassName,
textWrapperClassName = "max-w-sm",
}: Readonly<Props>) {
const handleToggle = () => {
if (disabled) return;
@@ -87,8 +91,8 @@ export default function FancyToggleSwitch({
)}
>
<div className={"flex justify-between gap-10"}>
<div className={"max-w-sm"}>
<Label>{label}</Label>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
</div>
<div className={"mt-2 pr-1"}>

View File

@@ -85,6 +85,7 @@ interface MultiSelectProps {
users?: User[];
placeholderForSearch?: string;
resourceIds?: string[];
additionalResources?: NetworkResource[];
policies?: Policy[];
}
export function PeerGroupSelector({
@@ -117,12 +118,21 @@ export function PeerGroupSelector({
users,
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
resourceIds,
additionalResources,
policies,
}: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const resources = useMemo(() => {
if (!additionalResources?.length) return fetchedResources;
const additional = additionalResources.filter(
(ar) => !fetchedResources?.some((r) => r.id === ar.id),
);
return [...(fetchedResources || []), ...additional];
}, [fetchedResources, additionalResources]);
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");

View File

@@ -8,6 +8,7 @@ type Props = {
description: ReactNode;
icon?: ReactNode;
className?: string;
disabled?: boolean;
};
export const RadioCard = ({
@@ -16,15 +17,18 @@ export const RadioCard = ({
description,
className,
icon,
disabled,
}: Props) => {
return (
<RadioGroup.Item
value={value}
disabled={disabled}
className={cn(
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
"hover:bg-nb-gray-930",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
className,
)}
>

View File

@@ -77,23 +77,56 @@ const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
extra?: React.ReactNode;
icon?: React.ReactNode;
description?: React.ReactNode;
}
>(({ className, children, extra, ...props }, ref) => (
>(({ className, children, extra, icon, description, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
"relative flex w-full select-none items-center rounded-md py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
icon ? "pl-2 pr-8" : "pl-8 pr-2",
description && "py-2",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{icon ? (
<>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex items-center gap-2">
<span className="flex-shrink-0">{icon}</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</div>
</>
) : (
<>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && (
<span className="text-xs text-nb-gray-300 font-normal">
{description}
</span>
)}
</div>
</>
)}
{extra}
</SelectPrimitive.Item>
));

View File

@@ -38,6 +38,7 @@ const ModalOverlay = React.forwardRef<
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
className,
)}
style={{ scrollbarGutter: "stable both-edges" }}
{...props}
/>
));

View File

@@ -48,6 +48,9 @@ interface SelectDropdownProps {
children?: React.ReactNode;
maxHeight?: number;
triggerClassName?: string;
iconSize?: number;
truncate?: boolean;
compact?: boolean;
}
export function SelectDropdown({
@@ -68,6 +71,9 @@ export function SelectDropdown({
children,
maxHeight,
triggerClassName,
iconSize = 14,
truncate = false,
compact = false,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -107,15 +113,18 @@ export function SelectDropdown({
const SelectedItem = () => {
return (
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
truncate && "min-w-0",
)}
>
<span className={"text-nb-gray-200"}>{selected?.label}</span>
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
{selected?.label}
</span>
</div>
</div>
);
@@ -216,20 +225,22 @@ export function SelectDropdown({
<ScrollArea
className={cn(
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
!showSearch && "pt-2",
"overflow-y-auto flex flex-col gap-1",
compact ? "pl-1 pr-1" : "pl-2 pr-3",
!showSearch && (compact ? "pt-1" : "pt-2"),
)}
style={{
maxHeight: maxHeight ?? 380,
}}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1 pb-2 w-full"}>
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
{filteredItems.map((option) => (
<SelectDropdownItem
option={option}
toggle={toggle}
key={option.value}
iconSize={iconSize}
showValue={showValues}
size={size}
/>
@@ -249,11 +260,13 @@ const SelectDropdownItem = ({
toggle,
showValue = false,
size = "sm",
iconSize = 14,
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
size: "xs" | "sm";
iconSize?: number;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -285,7 +298,12 @@ const SelectDropdownItem = ({
option?.disabled && "cursor-not-allowed",
)}
>
{option.icon && <option.icon size={14} width={14} />}
{option.icon && (
<div className={"shrink-0"}>
<option.icon size={iconSize} width={iconSize} />
</div>
)}
{option?.renderItem && option.renderItem()}
{!option?.renderItem && (
<div

View File

@@ -1,15 +1,20 @@
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import { cn } from "@utils/helpers";
export const SkeletonDeviceCard = () => {
type Props = {
className?: string;
};
export const SkeletonDeviceCard = ({ className = "min-h-[59px]" }: Props) => {
return (
<div className={"min-h-[59px] relative -left-2"}>
<div className={"py-2 pr-4 pl-2 flex gap-3"}>
<Skeleton height={36} width={36} />
<div className={"flex flex-col pr-[1.15rem]"}>
<Skeleton height={16} width={70} />
<Skeleton height={16} width={140} />
</div>
<div
className={cn("py-2 pr-4 pl-2 flex gap-3 relative -left-2", className)}
>
<Skeleton height={36} width={36} />
<div className={"flex flex-col pr-[1.15rem]"}>
<Skeleton height={16} width={70} />
<Skeleton height={16} width={140} />
</div>
</div>
);

View File

@@ -53,6 +53,7 @@ declare module "@tanstack/table-core" {
}
interface SortingFns {
checkbox: SortingFn<unknown>;
datetime: SortingFn<unknown>;
}
}
@@ -99,6 +100,15 @@ const arrIncludesSomeExact: FilterFn<any> = (
return value.some((val) => val === rowValue);
};
const datetimeSort: SortingFn<any> = (rowA, rowB, columnId) => {
const aConnected = rowA.original?.connected;
const bConnected = rowB.original?.connected;
if (aConnected !== bConnected) return aConnected ? 1 : -1;
const a = dayjs(rowA.getValue(columnId)).valueOf();
const b = dayjs(rowB.getValue(columnId)).valueOf();
return a - b;
};
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
const valueA =
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
@@ -324,6 +334,7 @@ export function DataTable<TData, TValue>({
},
sortingFns: {
checkbox: checkboxSort,
datetime: datetimeSort,
},
getRowId: useRowId ? (row) => row.id : undefined,
onRowSelectionChange: setRowSelection,

View File

@@ -14,6 +14,7 @@ type Props = {
center?: boolean;
className?: string;
sorting?: boolean;
onSort?: () => void;
name?: string;
};
export default function DataTableHeader({
@@ -23,14 +24,20 @@ export default function DataTableHeader({
center,
className,
sorting = true,
onSort,
name,
}: Props) {
const serverPagination = useOptionalServerPagination();
const handleSort = () => {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
if (onSort) {
onSort();
} else {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
}
if (name && serverPagination?.setSort) {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
serverPagination.setSort(name, direction);
}
};

View File

@@ -9,8 +9,11 @@ import { useCountries } from "@/contexts/CountryProvider";
type Props = {
value: string;
onChange: (value: string) => void;
iconSize?: number;
popoverWidth?: "auto" | "content" | number;
truncate?: boolean;
};
export const CountrySelector = ({ value, onChange }: Props) => {
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
@@ -22,7 +25,7 @@ export const CountrySelector = ({ value, onChange }: Props) => {
}) =>
createElement(RoundedFlag, {
country: country.country_code,
size: 20,
size: iconSize,
...props,
});
return {
@@ -42,7 +45,10 @@ export const CountrySelector = ({ value, onChange }: Props) => {
searchPlaceholder={"Search country..."}
value={value}
onChange={onChange}
iconSize={iconSize}
options={countryList || []}
popoverWidth={popoverWidth}
truncate={truncate}
/>
</div>
);

View File

@@ -13,7 +13,11 @@ const CountryContext = React.createContext(
countries: Country[] | undefined;
isLoading: boolean;
getRegionByPeer: (peer: Peer) => string;
getRegionText: (country_code: string, city_name: string) => string;
getRegionText: (
country_code: string,
city_name: string,
subdivision_code?: string,
) => string;
},
);
@@ -21,7 +25,11 @@ export default function CountryProvider({ children }: Props) {
const { isRestricted } = usePermissions();
const getRegionByPeer = (peer: Peer) => "Unknown";
const getRegionText = (country_code: string, city_name: string) => "Unknown";
const getRegionText = (
country_code: string,
city_name: string,
subdivision_code?: string,
) => "Unknown";
return isRestricted ? (
<CountryContext.Provider
@@ -47,12 +55,14 @@ function CountryProviderContent({ children }: Props) {
);
const getRegionText = useCallback(
(country_code: string, city_name: string) => {
(country_code: string, city_name: string, subdivision_code?: string) => {
if (!countries) return "Unknown";
const country = countries.find((c) => c.country_code === country_code);
if (!country) return "Unknown";
if (!city_name) return country.country_name;
return `${country.country_name}, ${city_name}`;
const parts = [country.country_name];
if (subdivision_code) parts.push(subdivision_code);
if (city_name) parts.push(city_name);
return parts.join(", ");
},
[countries],
);

View File

@@ -26,6 +26,7 @@ const PoliciesContext = React.createContext(
createPoliciesForResource: (
policies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => Promise<void>;
openEditPolicyModal: (policy: Policy, tab?: string) => void;
deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise<void>;
@@ -39,7 +40,7 @@ const PoliciesContext = React.createContext(
export default function PoliciesProvider({ children }: Props) {
const { mutate } = useSWRConfig();
const request = useApiCall<Policy>("/policies");
const { createOrUpdate: createOrUpdateGroup } = useGroups();
const { createOrUpdate: createOrUpdateGroup, groups } = useGroups();
const [policyModal, setPolicyModal] = useState(false);
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
const [initialPolicyTab, setInitialPolicyTab] = useState("");
@@ -49,26 +50,29 @@ export default function PoliciesProvider({ children }: Props) {
const createPolicyForResource = async (
policy: Policy,
resource: NetworkResource,
knownGroups?: Group[],
) => {
const rule = policy.rules[0];
const allGroups = [...(knownGroups || []), ...(groups || [])];
const resolveGroup = async (g: Group | string): Promise<string> => {
if (typeof g === "string") return g;
if (g.id) return g.id;
const existing = allGroups.find((eg) => eg.name === g.name);
if (existing?.id) return existing.id;
const created = await createOrUpdateGroup(g);
return created.id!;
};
const sources = await Promise.all(
(rule.sources ?? []).map((g) => {
if (typeof g === "string") return g;
if (g.id) return g.id;
return createOrUpdateGroup(g).then((r) => r.id);
}),
(rule.sources ?? []).map(resolveGroup),
).then((ids) => ids.filter(Boolean) as string[]);
const destinations = rule.destinationResource
? undefined
: await Promise.all(
(rule.destinations ?? []).map((g) => {
if (typeof g === "string") return g;
if (g.id) return g.id;
return createOrUpdateGroup(g).then((r) => r.id);
}),
).then((ids) => ids.filter(Boolean) as string[]);
: await Promise.all((rule.destinations ?? []).map(resolveGroup)).then(
(ids) => ids.filter(Boolean) as string[],
);
const destinationResource = rule.destinationResource
? { id: resource.id, type: resource.type }
@@ -93,22 +97,17 @@ export default function PoliciesProvider({ children }: Props) {
const createPoliciesForResource = async (
newPolicies: Policy[],
resource: NetworkResource,
knownGroups?: Group[],
) => {
const policiesToCreate = newPolicies.filter((p) => !p.id);
if (policiesToCreate.length === 0) return;
const promise = Promise.all(
policiesToCreate.map((p) => createPolicyForResource(p, resource)),
).then(() => mutate("/policies"));
notify({
title: "Create Policies",
description: "Successfully created policies for resource.",
promise,
showOnlyError: true,
});
return promise;
await Promise.all(
policiesToCreate.map((p) =>
createPolicyForResource(p, resource, knownGroups),
),
);
await mutate("/policies");
};
const serializeRules = (rules: Policy["rules"], enabled?: boolean) => {

View File

@@ -26,6 +26,8 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
type ReverseProxiesContextValue = {
reverseProxies: ReverseProxy[] | undefined;
resources: NetworkResource[] | undefined;
peers: Peer[] | undefined;
isLoading: boolean;
openModal: (options?: OpenModalOptions) => void;
openTargetModal: (options: OpenTargetModalOptions) => void;
@@ -93,7 +95,7 @@ export default function ReverseProxiesProvider({
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
"/reverse-proxies/services",
);
const request = useApiCall<ReverseProxy>("/reverse-proxies/services");
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
// Peers & Resources for resolving target destinations
const { data: peers } = useFetchApi<Peer[]>("/peers");
@@ -465,6 +467,8 @@ export default function ReverseProxiesProvider({
<ReverseProxiesContext.Provider
value={{
reverseProxies,
resources,
peers,
isLoading,
openModal,
openTargetModal,

View File

@@ -0,0 +1,17 @@
import { useAccount } from "@/modules/account/useAccount";
import { SSOIdentityProvider } from "@/interfaces/IdentityProvider";
import useFetchApi from "@utils/api";
export function useEmbeddedIdentityProviders() {
const account = useAccount();
const isEmbeddedIdPEnabled = !!account?.settings?.embedded_idp_enabled;
const { data: providers } = useFetchApi<SSOIdentityProvider[]>(
"/identity-providers",
true,
true,
isEmbeddedIdPEnabled,
);
return { providers, isEmbeddedIdPEnabled };
}

View File

@@ -1,37 +1,33 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
export default function useUrlTab(
validTabs: string[],
defaultTab: string,
paramName: string = "tab",
): [string, (value: string) => void] {
const searchParams = useSearchParams();
const router = useRouter();
const getTab = useCallback(
(params: URLSearchParams) => {
const tabParam = params.get("tab");
const tabParam = params.get(paramName);
if (tabParam && validTabs.includes(tabParam)) return tabParam;
return defaultTab;
},
[validTabs, defaultTab],
[validTabs, defaultTab, paramName],
);
const [tab, setTabState] = useState(() => getTab(searchParams));
useEffect(() => {
const newTab = getTab(searchParams);
setTabState(newTab);
}, [searchParams, getTab]);
const tab = useMemo(() => getTab(searchParams), [searchParams, getTab]);
const setTab = useCallback(
(value: string) => {
const nextTab = validTabs.includes(value) ? value : defaultTab;
setTabState(nextTab);
const params = new URLSearchParams(window.location.search);
params.set("tab", nextTab);
window.history.replaceState(null, "", `?${params.toString()}`);
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, nextTab);
router.replace(`?${params.toString()}`, { scroll: false });
},
[validTabs, defaultTab],
[searchParams, router, validTabs, defaultTab, paramName],
);
return [tab, setTab];

View File

@@ -26,6 +26,7 @@ export interface Account {
lazy_connection_enabled: boolean;
embedded_idp_enabled?: boolean;
auto_update_version: string;
auto_update_always: boolean;
local_auth_disabled?: boolean;
};
onboarding?: AccountOnboarding;

View File

@@ -1,20 +1,22 @@
export interface GoogleWorkspaceIntegration {
id: string;
customerId: string;
syncInterval: number;
customer_id: string;
sync_interval: number;
enabled: boolean;
group_prefixes: string[];
user_group_prefixes: string[];
connector_id?: string;
}
export interface AzureADIntegration {
id: string;
clientId: string;
tenantId: string;
syncInterval: number;
client_id: string;
tenant_id: string;
sync_interval: number;
enabled: boolean;
group_prefixes: string[];
user_group_prefixes: string[];
connector_id?: string;
}
export interface OktaIntegration {
@@ -23,6 +25,8 @@ export interface OktaIntegration {
group_prefixes: string[];
user_group_prefixes: string[];
auth_token: string;
connection_name?: string;
connector_id?: string;
}
export interface IdentityProviderLog {

View File

@@ -1,16 +1,43 @@
export enum ServiceMode {
HTTP = "http",
TCP = "tcp",
UDP = "udp",
TLS = "tls",
}
export interface ReverseProxy {
id?: string;
name: string;
domain: string;
mode?: ServiceMode;
listen_port?: number;
port_auto_assigned?: boolean;
proxy_cluster?: string;
targets: ReverseProxyTarget[];
enabled: boolean;
pass_host_header?: boolean;
rewrite_redirects?: boolean;
auth?: ReverseProxyAuth;
access_restrictions?: AccessRestrictions;
meta?: ReverseProxyMeta;
}
export const CrowdSecMode = {
OFF: "off",
ENFORCE: "enforce",
OBSERVE: "observe",
} as const;
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode;
}
export interface ReverseProxyMeta {
created_at: string;
status: ReverseProxyStatus;
@@ -31,8 +58,10 @@ export type ServiceTargetOptionsPathRewrite = "preserve";
export interface ServiceTargetOptions {
skip_tls_verify?: boolean;
request_timeout?: string;
session_idle_timeout?: string;
path_rewrite?: ServiceTargetOptionsPathRewrite;
custom_headers?: Record<string, string>;
proxy_protocol?: boolean;
}
export interface ReverseProxyTarget {
@@ -65,6 +94,13 @@ export interface ReverseProxyAuth {
link_auth?: {
enabled: boolean;
};
header_auths?: HeaderAuthConfig[];
}
export interface HeaderAuthConfig {
enabled: boolean;
header: string;
value: string;
}
export interface ReverseProxyDomain {
@@ -73,6 +109,9 @@ export interface ReverseProxyDomain {
validated: boolean;
type: ReverseProxyDomainType;
target_cluster?: string;
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
}
export enum ReverseProxyDomainType {
@@ -90,6 +129,15 @@ export enum ReverseProxyTargetType {
export enum ReverseProxyTargetProtocol {
HTTP = "http",
HTTPS = "https",
TCP = "tcp",
UDP = "udp",
}
export enum EventProtocol {
HTTP = "http",
TCP = "tcp",
UDP = "udp",
TLS = "tls",
}
export interface ReverseProxyEvent {
@@ -107,12 +155,33 @@ export interface ReverseProxyEvent {
auth_method_used?: string;
country_code?: string;
city_name?: string;
subdivision_code?: string;
bytes_upload: number;
bytes_download: number;
protocol?: EventProtocol;
metadata?: Record<string, string>;
}
export function isL4Event(event: ReverseProxyEvent): boolean {
return (
event.protocol === EventProtocol.TCP ||
event.protocol === EventProtocol.UDP ||
event.protocol === EventProtocol.TLS
);
}
export interface ReverseProxyFlatTarget extends ReverseProxyTarget {
proxy: ReverseProxy;
}
export function isL4Mode(mode?: ServiceMode): boolean {
return (
mode === ServiceMode.TCP ||
mode === ServiceMode.UDP ||
mode === ServiceMode.TLS
);
}
export const REVERSE_PROXY_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
@@ -139,3 +208,9 @@ export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
export const REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";

View File

@@ -46,6 +46,7 @@ import React, { useMemo, useState } from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
import { PostureCheck } from "@/interfaces/PostureCheck";
import { useAccessControl } from "@/modules/access-control/useAccessControl";
@@ -126,6 +127,7 @@ type ModalProps = {
initialDestinationResource?: PolicyRuleResource;
initialTab?: string;
disableDestinationSelector?: boolean;
additionalResources?: NetworkResource[];
};
export function AccessControlModalContent({
@@ -143,6 +145,7 @@ export function AccessControlModalContent({
initialDestinationResource,
initialTab,
disableDestinationSelector = false,
additionalResources,
}: Readonly<ModalProps>) {
const { permission } = usePermissions();
const { users } = useUsers();
@@ -392,6 +395,7 @@ export function AccessControlModalContent({
resource={destinationResource}
onResourceChange={setDestinationResource}
saveGroupAssignments={useSave}
additionalResources={additionalResources}
disabled={
disableDestinationSelector ||
!permission.policies.update ||

View File

@@ -821,6 +821,36 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Reverse Proxy
*/
if (event.activity_code == "service.create")
return (
<div className={"inline"}>
Service <Value>{m.domain}</Value> in cluster{" "}
<Value>{m.proxy_cluster}</Value> was created with authentication{" "}
<Value>{m.auth ? "Enabled" : "Disabled"}</Value>
</div>
);
if (event.activity_code == "service.update")
return (
<div className={"inline"}>
Service <Value>{m.domain}</Value> in cluster{" "}
<Value>{m.proxy_cluster}</Value> was updated with authentication{" "}
<Value>{m.auth === "true" ? "Enabled" : "Disabled"}</Value>
</div>
);
if (event.activity_code == "service.delete")
return (
<div className={"inline"}>
Service <Value>{m.domain}</Value> in cluster{" "}
<Value>{m.proxy_cluster}</Value> was deleted
</div>
);
return (
<div className={"flex gap-2.5 items-center"}>
<span className={"mb-[1px]"}>{event.activity}</span>

View File

@@ -49,7 +49,9 @@ export function ActivityEventCodeSelector({
return {
activity_code: event.activity_code,
activity: event.activity,
group: event.activity_code.split(".")[0],
group: event.activity_code.startsWith("service.user")
? "Service User"
: event.activity_code.split(".")[0],
};
});
return items.reduce((acc, item) => {

View File

@@ -11,7 +11,6 @@ import {
KeyRound,
Layers3Icon,
LogIn,
type LucideIcon,
MonitorSmartphoneIcon,
NetworkIcon,
RefreshCcw,
@@ -21,6 +20,7 @@ import {
User,
} from "lucide-react";
import React from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
type Props = {
code: string;
@@ -46,7 +46,7 @@ const ActivityTypeMappings = {
dashboard: LogIn,
integration: Blocks,
personal: User,
service: Cog,
"service.user": Cog,
billing: CreditCardIcon,
integrated: ShieldCheck,
posture: ShieldCheck,
@@ -54,21 +54,24 @@ const ActivityTypeMappings = {
resource: Layers3Icon,
network: NetworkIcon,
identityprovider: FingerprintIcon,
} as const satisfies Record<string, LucideIcon>;
service: ReverseProxyIcon,
} as const;
export default function ActivityTypeIcon({
code,
size = 18,
className,
}: Props) {
const prefixParts = code?.split(".") || [];
const prefix = (prefixParts[0] || "").toLowerCase();
const parts = code?.split(".") || [];
const twoPartKey = parts.slice(0, 2).join(".").toLowerCase();
const onePartKey = (parts[0] || "").toLowerCase();
const key = (
twoPartKey in ActivityTypeMappings ? twoPartKey : onePartKey
) as ActivityTypeKey;
// Check if prefix is a valid key, otherwise use fallback
const Icon =
prefix in ActivityTypeMappings
? ActivityTypeMappings[prefix as ActivityTypeKey]
: HelpCircleIcon;
key in ActivityTypeMappings ? ActivityTypeMappings[key] : HelpCircleIcon;
return <Icon size={size} className={cn(DEFAULT_CLASSES, className)} />;
}

View File

@@ -91,10 +91,11 @@ export function DNSZoneModalContent({
if (domain == "") return "";
const valid = validator.isValidDomain(domain, {
allowWildcard: false,
allowOnlyTld: false,
allowOnlyTld: true,
preventLeadingAndTrailingDots: true,
});
if (!valid) {
return "Please enter a valid domain, e.g. company.internal or intra.example.com";
return "Please enter a valid domain, e.g. internal, company.internal or intra.example.com";
}
}, [domain]);

View File

@@ -1,5 +1,6 @@
import React from "react";
import { useGroupContext } from "@/contexts/GroupProvider";
import PeersProvider from "@/contexts/PeersProvider";
import { Route } from "@/interfaces/Route";
import { GroupDetailsTableContainer } from "@/modules/groups/details/GroupDetailsTableContainer";
import NetworkRoutesTable from "@/modules/route-group/NetworkRoutesTable";
@@ -18,14 +19,16 @@ export const GroupNetworkRoutesSection = ({
const { group } = useGroupContext();
return (
<GroupDetailsTableContainer>
<NetworkRoutesTable
isGroupPage={true}
isLoading={isLoading}
groupedRoutes={groupedRoutes}
routes={routes}
distributionGroups={[group]}
/>
</GroupDetailsTableContainer>
<PeersProvider>
<GroupDetailsTableContainer>
<NetworkRoutesTable
isGroupPage={true}
isLoading={isLoading}
groupedRoutes={groupedRoutes}
routes={routes}
distributionGroups={[group]}
/>
</GroupDetailsTableContainer>
</PeersProvider>
);
};

View File

@@ -192,6 +192,7 @@ export const NetworkProvider = ({
? [...fetchedResources, additionalResource]
: fetchedResources;
const isMulti = affectedResources.length > 1;
if (!isMulti && action === "edit") return true;
return confirm({
title: isMulti ? (
<>This policy is used by multiple resources</>
@@ -206,8 +207,8 @@ export const NetworkProvider = ({
action === "edit" ? "Updating" : "Deleting"
} this policy will also affect following resources:`
: action === "delete"
? "Are you sure you want to delete this policy? This action cannot be undone."
: undefined,
? "Are you sure you want to delete this policy? This action cannot be undone."
: undefined,
children: isMulti ? (
<AffectedResourceList resources={affectedResources} />
) : undefined,

View File

@@ -68,7 +68,7 @@ export default function NetworkResourceAccessControl({
const currentResource = useMemo<NetworkResource>(() => {
return {
id: resourceId || "",
id: resourceId || resourceName || address,
name: resourceName || address,
address,
type: getResourceType(address),
@@ -289,7 +289,8 @@ export default function NetworkResourceAccessControl({
editingPolicyIndex === null ? destinationResource : undefined
}
disableDestinationSelector={!hasResourceGroups}
initialName={`${resourceName || address} Policy`}
additionalResources={[currentResource]}
initialName={`${resourceName || address} Access`}
initialDescription={
network?.description
? `${network.name}, ${network.description}`

View File

@@ -2,7 +2,6 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import { Input } from "@components/Input";
@@ -26,11 +25,15 @@ import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import {
ExternalLinkIcon,
PlusCircle,
Power,
ShieldCheck,
Text,
WorkflowIcon,
} from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@components/Accordion";
import React, { useMemo, useRef, useState } from "react";
import { Group } from "@/interfaces/Group";
import { Network, NetworkResource } from "@/interfaces/Network";
@@ -133,9 +136,7 @@ export function ResourceModalContent({
return allPolicies.filter((policy) => {
const rule = policy.rules?.[0];
if (!rule || rule.destinationResource) return false;
const destinations = rule.destinations as
| (Group | string)[]
| undefined;
const destinations = rule.destinations as (Group | string)[] | undefined;
return destinations?.some((d) => {
const id = typeof d === "string" ? d : d.id;
return !!id && groupIds.has(id);
@@ -175,7 +176,7 @@ export function ResourceModalContent({
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {
await createPoliciesForResource(policies, r);
await createPoliciesForResource(policies, r, savedGroups);
onCreated?.(r);
});
@@ -199,7 +200,7 @@ export function ResourceModalContent({
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {
await createPoliciesForResource(policies, r);
await createPoliciesForResource(policies, r, savedGroups);
onUpdated?.(r);
});
notify({
@@ -217,7 +218,7 @@ export function ResourceModalContent({
return (
<ModalContent
maxWidthClass={
tab === "access-control" ? "max-w-[790px]" : "max-w-[720px]"
tab === "access-control" ? "max-w-[790px]" : "max-w-[680px]"
}
>
<ModalHeader
@@ -239,32 +240,34 @@ export function ResourceModalContent({
</TabsTrigger>
<TabsTrigger
value={"access-control"}
disabled={!resource && !isAddressValid}
disabled={!resource && !canCreate}
>
<ShieldCheck size={16} />
Access Control
</TabsTrigger>
<TabsTrigger
value={"general"}
disabled={!resource && !isAddressValid}
>
<Text
size={16}
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
/>
Name & Description
</TabsTrigger>
</TabsList>
<TabsContent value={"resource"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-8"}>
<TabsContent value={"resource"} className={"pb-4"}>
<div className={"px-8 flex-col flex gap-6"}>
<div>
<Label>Name</Label>
<HelpText>
Set an easily identifiable name for your resource
</HelpText>
<Input
ref={nameRef}
autoFocus={true}
tabIndex={0}
placeholder={"e.g., Postgres Database"}
value={name}
error={nameError}
onChange={(e) => setName(e.target.value)}
/>
</div>
<ResourceSingleAddressInput
value={address}
onChange={setAddress}
onError={setAddressError}
autoFocus={true}
description={
<>
Enter a single{" "}
@@ -295,59 +298,83 @@ export function ResourceModalContent({
}
/>
<div>
<Label>Resource Groups (optional)</Label>
<HelpText>
Organize this resource into a group (e.g., Databases, Web
Servers) and reference the group in access policies to keep
rules reusable and easy to maintain.
</HelpText>
<PeerGroupSelector
side={"top"}
onChange={setGroups}
values={groups}
showPeerCounter={false}
placeholder={"Add or select resource group(s)..."}
policies={allPolicies}
/>
{groupPolicyCount > 0 && (
<Callout variant={"info"} className={"mt-3"}>
Your selected resource groups are used in{" "}
<span className="text-white font-medium">
{groupPolicyCount} Access Control{" "}
{groupPolicyCount === 1 ? "Policy" : "Policies"}
<Accordion
type={"multiple"}
className={"flex flex-col gap-2 -mt-2"}
>
<AccordionItem value={"resource-groups"}>
<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>
. This resource will inherit access from{" "}
{groupPolicyCount === 1 ? "this policy" : "these policies"}.
{isAddressValid || resource ? (
<>
{" "}
Please review them in the{" "}
<InlineButtonLink
onClick={() => setTab("access-control")}
variant={"dashed"}
>
Access Control
</InlineButtonLink>{" "}
tab.
</>
) : (
" Please review them in the Access Control tab."
)}
</Callout>
)}
</div>
<FancyToggleSwitch
value={enabled}
onChange={setEnabled}
label={
<>
<Power size={15} />
Enable Resource
</>
}
helpText={"Use this switch to enable or disable the resource."}
/>
</AccordionTrigger>
<AccordionContent className={""}>
<div className={"flex flex-col gap-6 pb-4 pt-2"}>
<div>
<Label>Description</Label>
<HelpText>
Write a short description to add more context to this
resource.
</HelpText>
<Input
placeholder={"e.g., Production, Development"}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div>
<Label>Resource Groups</Label>
<HelpText className={"mt-1"}>
Add this resource to a group (e.g., Databases, Web
Servers) and reference the group <br /> in access
policies to simplify management.
</HelpText>
<PeerGroupSelector
side={"top"}
onChange={setGroups}
values={groups}
showPeerCounter={false}
placeholder={"Add or select resource group(s)..."}
policies={allPolicies}
/>
{groupPolicyCount > 0 && (
<Callout variant={"info"} className={"mt-3"}>
Your selected resource groups are used in{" "}
<span className="text-white font-medium">
{groupPolicyCount} Access Control{" "}
{groupPolicyCount === 1 ? "Policy" : "Policies"}
</span>
. This resource will inherit access from{" "}
{groupPolicyCount === 1
? "this policy"
: "these policies"}
.
{isAddressValid || resource ? (
<>
{" "}
Please review them in the{" "}
<InlineButtonLink
onClick={() => setTab("access-control")}
variant={"dashed"}
>
Access Control
</InlineButtonLink>{" "}
tab.
</>
) : (
" Please review them in the Access Control tab."
)}
</Callout>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</TabsContent>
@@ -362,36 +389,6 @@ export function ResourceModalContent({
hasResourceGroups={groups.length > 0}
/>
</TabsContent>
<TabsContent value={"general"} className={"px-8 pb-8"}>
<div className={"flex flex-col gap-6"}>
<div>
<Label>Name</Label>
<HelpText>
Set an easily identifiable name for your resource
</HelpText>
<Input
ref={nameRef}
tabIndex={0}
placeholder={"e.g., Postgres Database"}
value={name}
error={nameError}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<Label>Description (optional)</Label>
<HelpText>
Write a short description to add more context to this resource.
</HelpText>
<Input
placeholder={"e.g., Production, Development"}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
</TabsContent>
</Tabs>
<ModalFooter className={"items-center"}>
@@ -418,7 +415,7 @@ export function ResourceModalContent({
<Button
variant={"primary"}
onClick={() => setTab("access-control")}
disabled={!isAddressValid}
disabled={!canCreate}
>
Continue
</Button>
@@ -433,26 +430,6 @@ export function ResourceModalContent({
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => {
setTab("general");
setTimeout(() => nameRef.current?.focus(), 0);
}}
>
Continue
</Button>
</>
)}
{tab === "general" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab("access-control")}
>
Back
</Button>
<Button
variant={"primary"}
data-cy={"submit-route"}

View File

@@ -12,6 +12,9 @@ import { useApiCall } from "@utils/api";
import * as React from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
import { FolderGit2 } from "lucide-react";
import Separator from "@components/Separator";
type ResourceGroupModalProps = {
resource?: NetworkResource;
@@ -56,6 +59,7 @@ const ResourceGroupModalContent = ({
`/networks/${network?.id}/resources/${resource?.id}`,
).put;
const { policies } = useNetworksContext();
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
initial: resource?.groups || [],
});
@@ -80,17 +84,21 @@ const ResourceGroupModalContent = ({
<ModalHeader
title={"Resource Groups"}
description={
"Organize this resource into a group (e.g., Databases, Web Servers) and reference the group in access policies to keep rules reusable and easy to maintain."
"Add this resource to a group (e.g., Databases, Web Servers) and reference the group in access policies to simplify management."
}
icon={<FolderGit2 size={18} />}
/>
<div className={"px-8 py-6 pt-0 flex flex-col gap-8"}>
<Separator />
<div className={"px-8 py-6 pt-6 flex flex-col gap-8"}>
<div>
<PeerGroupSelector
onChange={setGroups}
values={groups}
showPeerCounter={false}
placeholder={"Add or select resource group(s)..."}
policies={policies}
/>
</div>
</div>

View File

@@ -138,8 +138,18 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
},
{
accessorKey: "last_seen",
header: ({ column }) => {
return <DataTableHeader column={column}>Last seen</DataTableHeader>;
header: ({ column, table }) => {
return (
<DataTableHeader
column={column}
onSort={() => {
const desc = column.getIsSorted() === "desc";
table.setSorting([{ id: "last_seen", desc: !desc }]);
}}
>
Last seen
</DataTableHeader>
);
},
sortingFn: "datetime",
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
@@ -226,17 +236,13 @@ export default function PeersTable({
"netbird-table-sort" + path,
[
{
id: "connected",
id: "last_seen",
desc: true,
},
{
id: "name",
desc: false,
},
{
id: "last_seen",
desc: true,
},
],
);

View File

@@ -0,0 +1,359 @@
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import Button from "@components/Button";
import { Input } from "@components/Input";
import cidr from "ip-cidr";
import {
FlagIcon,
MinusCircleIcon,
NetworkIcon,
PlusIcon,
ShieldCheckIcon,
ShieldXIcon,
WorkflowIcon,
} from "lucide-react";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { CountrySelector } from "@/components/ui/CountrySelector";
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr";
const ACTION_OPTIONS: SelectOption[] = [
{
label: "Allow Only",
value: "allow",
icon: (props) => <ShieldCheckIcon {...props} className="text-green-500" />,
},
{
label: "Block Only",
value: "block",
icon: (props) => <ShieldXIcon {...props} className="text-red-500" />,
},
];
const TYPE_OPTIONS: SelectOption[] = [
{
label: "Country",
value: "country",
icon: (props) => <FlagIcon {...props} />,
},
{
label: "IP Address",
value: "ip",
icon: (props) => <WorkflowIcon {...props} />,
},
{
label: "CIDR Block",
value: "cidr",
icon: (props) => <NetworkIcon {...props} />,
},
];
type AccessRule = {
id: string;
action: AccessAction;
type: AccessRuleType;
value: string;
};
type RulesAction =
| { type: "add" }
| { type: "remove"; id: string }
| {
type: "update";
id: string;
field: "action" | "type" | "value";
value: string;
};
const nextId = () => crypto.randomUUID();
function rulesReducer(state: AccessRule[], action: RulesAction): AccessRule[] {
switch (action.type) {
case "add":
return [
...state,
{ id: nextId(), action: "allow", type: "country", value: "" },
];
case "remove":
return state.filter((r) => r.id !== action.id);
case "update":
return state.map((r) => {
if (r.id !== action.id) return r;
if (action.field === "type") {
return { ...r, type: action.value as AccessRuleType, value: "" };
}
return { ...r, [action.field]: action.value };
});
}
}
function pushCidrRules(
rules: AccessRule[],
values: string[] | undefined,
action: AccessAction,
) {
values?.forEach((v) => {
const isIp = v.includes(":") ? v.endsWith("/128") : v.endsWith("/32");
rules.push({
id: nextId(),
action,
type: isIp ? "ip" : "cidr",
value: isIp ? v.replace(/\/(32|128)$/, "") : v,
});
});
}
function restrictionsToRules(
restrictions: AccessRestrictions | undefined,
): AccessRule[] {
if (!restrictions) return [];
const rules: AccessRule[] = [];
pushCidrRules(rules, restrictions.blocked_cidrs, "block");
restrictions.blocked_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "block", type: "country", value: v }),
);
pushCidrRules(rules, restrictions.allowed_cidrs, "allow");
restrictions.allowed_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
);
return rules;
}
function rulesToRestrictions(
rules: AccessRule[],
crowdsecMode?: CrowdSecMode,
): AccessRestrictions | undefined {
const allowed_countries: string[] = [];
const blocked_countries: string[] = [];
const allowed_cidrs: string[] = [];
const blocked_cidrs: string[] = [];
for (const rule of rules) {
if (!rule.value) continue;
if (rule.type === "country") {
if (rule.action === "allow") allowed_countries.push(rule.value);
else blocked_countries.push(rule.value);
} else {
const suffix = rule.value.includes(":") ? "/128" : "/32";
const value =
rule.type === "ip" && !rule.value.includes("/")
? `${rule.value}${suffix}`
: rule.value;
if (rule.action === "allow") allowed_cidrs.push(value);
else blocked_cidrs.push(value);
}
}
const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
const hasAny =
allowed_countries.length > 0 ||
blocked_countries.length > 0 ||
allowed_cidrs.length > 0 ||
blocked_cidrs.length > 0 ||
hasCrowdSec;
if (!hasAny) return undefined;
return {
...(allowed_countries.length > 0 && { allowed_countries }),
...(blocked_countries.length > 0 && { blocked_countries }),
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
};
}
type Props = {
value: AccessRestrictions | undefined;
onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean;
};
function validateRule(rule: AccessRule): string {
if (rule.type === "country" || !rule.value) return "";
if (rule.type === "ip") {
const val = rule.value.includes("/") ? rule.value : `${rule.value}/32`;
if (!cidr.isValidAddress(val)) {
return "Please enter a valid IP address, e.g., 85.203.15.42";
}
} else {
if (!rule.value.includes("/") || !cidr.isValidAddress(rule.value)) {
return "Please enter a valid CIDR block, e.g., 74.125.0.0/16";
}
}
return "";
}
export const ReverseProxyAccessControlRules = ({
value,
onChange,
onValidationChange,
supportsCrowdSec,
}: Props) => {
const [rules, dispatch] = useReducer(
rulesReducer,
value,
restrictionsToRules,
);
const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
value?.crowdsec_mode ?? CrowdSecMode.OFF,
);
const errors = useMemo(
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
[rules],
);
const hasErrors = useMemo(
() => Object.values(errors).some((e) => e !== ""),
[errors],
);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onValidationChangeRef = useRef(onValidationChange);
onValidationChangeRef.current = onValidationChange;
useEffect(() => {
if (!supportsCrowdSec) {
setCrowdsecMode(CrowdSecMode.OFF);
}
}, [supportsCrowdSec]);
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
}, [rules, crowdsecMode]);
useEffect(() => {
onValidationChangeRef.current?.(hasErrors);
}, [hasErrors]);
return (
<div className={"flex-col flex"}>
{supportsCrowdSec && (
<ReverseProxyCrowdSecIPReputation
value={crowdsecMode}
onChange={setCrowdsecMode}
/>
)}
<div>
<Label>Access Control Rules</Label>
<HelpText>
Define rules to allow or block traffic based on country, IP address,
or CIDR block.
<br />
Block rules always take priority over allow rules.
</HelpText>
</div>
{rules.length > 0 && (
<div className="flex flex-col gap-3 mt-1 mb-4">
{rules.map((rule) => (
<div key={rule.id} className="flex items-center">
<div className="w-[160px] shrink-0 [&_button]:rounded-r-none [&_button]:w-[160px]">
<SelectDropdown
value={rule.action}
onChange={(v) =>
dispatch({
type: "update",
id: rule.id,
field: "action",
value: v,
})
}
options={ACTION_OPTIONS}
compact
/>
</div>
<div className="w-[160px] shrink-0 -ml-px [&_button]:rounded-none [&_button]:w-[160px]">
<SelectDropdown
value={rule.type}
onChange={(v) =>
dispatch({
type: "update",
id: rule.id,
field: "type",
value: v,
})
}
options={TYPE_OPTIONS}
compact
/>
</div>
<div className="flex-1 min-w-0 -ml-px [&_button]:rounded-l-none [&_input]:rounded-l-none">
{rule.type === "country" ? (
<CountrySelector
iconSize={16}
popoverWidth={350}
truncate
value={rule.value}
onChange={(v) =>
dispatch({
type: "update",
id: rule.id,
field: "value",
value: v,
})
}
/>
) : (
<Input
placeholder={
rule.type === "ip"
? "e.g., 85.203.15.42"
: "e.g., 74.125.0.0/16"
}
value={rule.value}
onChange={(e) =>
dispatch({
type: "update",
id: rule.id,
field: "value",
value: e.target.value,
})
}
error={errors[rule.id]}
errorTooltip={true}
maxWidthClass="w-full"
/>
)}
</div>
<Button
variant="default-outline"
className="h-[42px] w-[42px] !px-0 shrink-0 ml-2"
onClick={() => dispatch({ type: "remove", id: rule.id })}
aria-label="Remove rule"
>
<MinusCircleIcon size={14} />
</Button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button
variant="dotted"
className="flex-1"
size="sm"
onClick={() => dispatch({ type: "add" })}
>
<PlusIcon size={14} />
Add Rule
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,109 @@
import * as React from "react";
import { ReactNode } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { EyeIcon, PowerOffIcon, ShieldCheckIcon } from "lucide-react";
import { HelpTooltip } from "@components/HelpTooltip";
import { CrowdSecMode } from "@/interfaces/ReverseProxy";
import Image from "next/image";
import CrowdSecIconImage from "@/assets/integrations/crowdsec.png";
type Props = {
value: CrowdSecMode;
onChange: (value: CrowdSecMode) => void;
};
type CrowdSecOption = {
label: string;
description?: string;
icon: ReactNode;
};
const CROWDSEC_OPTIONS: Record<CrowdSecMode, CrowdSecOption> = {
[CrowdSecMode.OFF]: {
label: "Disabled",
icon: <PowerOffIcon size={14} />,
},
[CrowdSecMode.ENFORCE]: {
label: "Enforce",
description:
"Blocked IPs are denied immediately. If the bouncer is not yet synced, connections are denied (fail-closed).",
icon: <ShieldCheckIcon size={14} />,
},
[CrowdSecMode.OBSERVE]: {
label: "Observe",
description:
"Blocked IPs are logged but not denied. Use this to evaluate CrowdSec before enforcing.",
icon: <EyeIcon size={14} />,
},
};
export const ReverseProxyCrowdSecIPReputation = ({
value,
onChange,
}: Props) => {
const selected = CROWDSEC_OPTIONS[value];
return (
<div className="flex items-center gap-0 justify-between mb-6">
<div className="flex gap-4">
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70 shrink-0 relative"
}
>
<Image
src={CrowdSecIconImage}
alt={"CrowdSec"}
className={"rounded-[4px]"}
/>
</div>
<div>
<Label>CrowdSec IP Reputation</Label>
<HelpText>
Detect malicious IPs with CrowdSec.{" "}
<b className={"text-white"}>Enforce</b> to block them or{" "}
<b className={"text-white"}>Observe</b> to only log without
blocking.
</HelpText>
</div>
</div>
<Select value={value} onValueChange={(v) => onChange(v as CrowdSecMode)}>
<SelectTrigger className="w-[260px]">
<div className="flex items-center gap-2 whitespace-nowrap">
{selected.icon}
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{Object.entries(CROWDSEC_OPTIONS).map(([mode, config]) => (
<SelectItem
key={mode}
value={mode}
extra={
config.description ? (
<HelpTooltip
triggerClassName="ml-[0.01rem]"
align="center"
side="right"
content={<>{config.description}</>}
/>
) : undefined
}
>
<span className="whitespace-nowrap">{config.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};

View File

@@ -0,0 +1,171 @@
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>HTTPS 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>
);
}

View 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={isListenPortSupported ? "max-w-sm" : "max-w-xs"}
content={
isListenPortSupported
? "Enter the public listen port this service will be reachable on."
: "The listen port will be automatically assigned after the service is created."
}
/>
</Label>
<div className={"mt-2"}>
<Input
ref={listenPortRef}
type="number"
min={1}
max={65535}
placeholder={!isListenPortSupported ? "Auto" : "443"}
value={!isListenPortSupported ? "" : listenPort || ""}
onChange={(e) => setListenPort(parseInt(e.target.value) || 0)}
disabled={!isListenPortSupported || !l4Target}
aria-label="Public listen port"
/>
</div>
</div>
<ArrowRight size={16} className="text-nb-gray-400 shrink-0 mt-6" />
<div className={"w-full flex"}>
<div className={"w-full"}>
<Label>
Host / IP
<CidrHelpText target={l4Target} />
</Label>
<div className="flex w-full mt-2 relative">
<ReverseProxyAddressInput
value={l4Target}
onChange={setL4Target}
/>
</div>
</div>
<div>
<Label>
Port
<HelpTooltip
content={
"Enter the port where your service (e.g., webserver, app, API) is currently listening."
}
/>
</Label>
<div className={"mt-2 min-w-[120px]"}>
<Input
ref={portRef}
type="number"
min={1}
max={65535}
placeholder="443"
value={port || ""}
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
disabled={!l4Target}
aria-label="Destination port"
className={"rounded-l-none"}
/>
</div>
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View 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: "HTTPS 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 !== undefined;
// 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>
);
};

View File

@@ -0,0 +1,454 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import { Input } from "@components/Input";
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import {
BracesIcon,
CircleUserIcon,
FileCode2Icon,
KeyRoundIcon,
MinusCircleIcon,
PlusIcon,
UserIcon,
} from "lucide-react";
import React, { useMemo, useReducer, useRef } from "react";
import { useHasChanges } from "@/hooks/useHasChanges";
import type { HeaderAuthConfig } from "@/interfaces/ReverseProxy";
type HeaderType = "basic" | "bearer" | "custom";
interface HeaderAuthItem {
id: string;
type: HeaderType;
header: string;
value: string;
username: string;
password: string;
existingSecret: boolean;
}
const HEADER_TYPE_OPTIONS: SelectOption[] = [
{
value: "basic" satisfies HeaderType,
label: "Basic Auth",
icon: () => <CircleUserIcon size={14} />,
},
{
value: "bearer" satisfies HeaderType,
label: "Bearer Token",
icon: () => <KeyRoundIcon size={14} />,
},
{
value: "custom" satisfies HeaderType,
label: "Custom Header",
icon: () => <BracesIcon size={14} />,
},
];
const MASKED_VALUE = "••••••••";
const INPUT_PROPS = {
autoComplete: "off",
"data-1p-ignore": true,
"data-lpignore": "true",
"data-form-type": "other",
} as const;
function createHeaderEntry(
overrides?: Partial<HeaderAuthItem>,
): HeaderAuthItem {
return {
id: crypto.randomUUID(),
type: "basic",
header: "Authorization",
value: "",
username: "",
password: "",
existingSecret: false,
...overrides,
};
}
function toBase64(str: string): string {
return btoa(
new TextEncoder()
.encode(str)
.reduce((acc, byte) => acc + String.fromCharCode(byte), ""),
);
}
function fromBase64(b64: string): string {
return new TextDecoder().decode(
Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)),
);
}
function headerEntryToConfig(entry: HeaderAuthItem): HeaderAuthConfig {
if (entry.existingSecret) {
const value = entry.value === MASKED_VALUE ? "" : entry.value;
return { enabled: true, header: entry.header, value };
}
switch (entry.type) {
case "basic": {
const encoded = toBase64(`${entry.username}:${entry.password}`);
return {
enabled: true,
header: "Authorization",
value: `Basic ${encoded}`,
};
}
case "bearer":
return {
enabled: true,
header: "Authorization",
value: `Bearer ${entry.value}`,
};
case "custom":
return { enabled: true, header: entry.header, value: entry.value };
}
}
function configToHeaderEntry(config: HeaderAuthConfig): HeaderAuthItem {
const isExisting = !config.value;
if (config.header === "Authorization" && config.value?.startsWith("Basic ")) {
try {
const decoded = fromBase64(config.value.slice(6));
const sep = decoded.indexOf(":");
if (sep >= 0) {
return createHeaderEntry({
type: "basic",
username: decoded.slice(0, sep),
password: decoded.slice(sep + 1),
});
}
} catch {}
}
if (
config.header === "Authorization" &&
config.value?.startsWith("Bearer ")
) {
return createHeaderEntry({ type: "bearer", value: config.value.slice(7) });
}
return createHeaderEntry({
type: isExisting && config.header === "Authorization" ? "basic" : "custom",
header: config.header,
value: isExisting ? MASKED_VALUE : config.value ?? "",
existingSecret: isExisting,
});
}
function isHeaderValid(entry: HeaderAuthItem): boolean {
if (entry.existingSecret) return true;
switch (entry.type) {
case "basic":
return entry.username.trim().length > 0 && entry.password.length > 0;
case "bearer":
return entry.value.trim().length > 0;
case "custom":
return entry.header.trim().length > 0 && entry.value.trim().length > 0;
}
}
type HeaderAction =
| { type: "add" }
| { type: "remove"; index: number }
| { type: "update"; index: number; updates: Partial<HeaderAuthItem> };
function headersReducer(
state: HeaderAuthItem[],
action: HeaderAction,
): HeaderAuthItem[] {
switch (action.type) {
case "add":
return [...state, createHeaderEntry()];
case "remove":
return state.length === 1
? [createHeaderEntry()]
: state.filter((_, i) => i !== action.index);
case "update":
return state.map((e, i) =>
i === action.index ? { ...e, ...action.updates } : e,
);
}
}
function initHeaders(headers: HeaderAuthConfig[]): HeaderAuthItem[] {
return headers.length > 0
? headers.map(configToHeaderEntry)
: [createHeaderEntry()];
}
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
currentHeaders: HeaderAuthConfig[];
onSave: (headers: HeaderAuthConfig[]) => void;
onRemove: () => void;
};
export default function AuthHeaderModal({
open,
onOpenChange,
currentHeaders,
onSave,
onRemove,
}: Readonly<Props>) {
const [items, dispatch] = useReducer(
headersReducer,
currentHeaders,
initHeaders,
);
const isEditing = currentHeaders.length > 0;
const canSave = useMemo(() => items.every(isHeaderValid), [items]);
const { hasChanges } = useHasChanges(items);
const handleSave = () => {
if (!canSave) return;
onOpenChange(false);
onSave(items.map(headerEntryToConfig));
};
const handleRemoveAll = () => {
onOpenChange(false);
onRemove();
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent
maxWidthClass="max-w-xl"
onOpenAutoFocus={(e) => {
e.preventDefault();
const container = e.currentTarget as HTMLElement | null;
container
?.querySelector<HTMLInputElement>("input:not([type=hidden])")
?.focus();
}}
>
<ModalHeader
title="HTTP Headers"
description="Require specific HTTP headers to access this service."
/>
<div className="px-8">
<div className="flex flex-col gap-3">
{items.map((item, index) => (
<HeaderItemRow
key={item.id}
item={item}
index={index}
onChange={(updates) =>
dispatch({ type: "update", index, updates })
}
onRemove={() => dispatch({ type: "remove", index })}
showRemove={items.length > 1}
/>
))}
</div>
<Button
variant="dotted"
className="w-full mt-4"
size="sm"
onClick={() => dispatch({ type: "add" })}
>
<PlusIcon size={14} />
Add Header
</Button>
{items.length > 1 && (
<Callout className="mt-4" variant="info">
Any request matching one of these headers will grant access.
<br />
Matched headers are stripped before reaching your backend.
</Callout>
)}
<div className="flex gap-3 w-full justify-between mt-6">
{isEditing ? (
<>
<Button variant="danger-text" onClick={handleRemoveAll}>
Remove All
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={!canSave || !hasChanges}
>
Save
</Button>
</div>
</>
) : (
<>
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={!canSave}
>
Add Headers
</Button>
</div>
</>
)}
</div>
</div>
</ModalContent>
</Modal>
);
}
type HeaderItemRowProps = {
item: HeaderAuthItem;
index: number;
onChange: (updates: Partial<HeaderAuthItem>) => void;
onRemove: () => void;
showRemove: boolean;
};
function HeaderItemRow({
item,
index,
onChange,
onRemove,
showRemove,
}: Readonly<HeaderItemRowProps>) {
const isMaskedRef = useRef(item.existingSecret);
const handleHeaderTypeChange = (value: string) => {
const type = value as HeaderType;
onChange({
type,
header: type === "custom" ? "" : "Authorization",
value: "",
username: "",
password: "",
});
};
return (
<div className="rounded-md border border-nb-gray-900 bg-nb-gray-920/30 overflow-hidden">
<div className="flex flex-col gap-2 px-4 pt-2 pb-4 bg-nb-gray-920/30">
<div className="flex items-center justify-between h-6 mt-0.5">
<span className="text-xs font-normal text-nb-gray-200 flex items-center gap-2">
<FileCode2Icon size={14} />
{item.existingSecret
? `Header ${index + 1} - ${item.header}`
: `Header ${index + 1}`}
</span>
{showRemove && (
<Button variant="danger-text" size="xs" onClick={onRemove}>
<MinusCircleIcon size={12} />
Remove
</Button>
)}
</div>
{item.existingSecret ? (
<div>
<Input
customPrefix={<span className="min-w-[38px]">Value</span>}
type="password"
showPasswordToggle={!isMaskedRef.current}
value={isMaskedRef.current ? MASKED_VALUE : item.value}
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
{...INPUT_PROPS}
onChange={(e) => {
if (isMaskedRef.current) {
isMaskedRef.current = false;
const nativeEvent = e.nativeEvent as InputEvent;
onChange({ value: nativeEvent.data ?? "" });
return;
}
onChange({ value: e.target.value });
}}
/>
</div>
) : (
<>
<SelectDropdown
value={item.type}
onChange={handleHeaderTypeChange}
options={HEADER_TYPE_OPTIONS}
/>
{item.type === "basic" && (
<div className="flex flex-col gap-2">
<Input
customPrefix={<UserIcon size={16} />}
placeholder="Username"
maxWidthClass="w-full"
value={item.username}
onChange={(e) => onChange({ username: e.target.value })}
{...INPUT_PROPS}
/>
<Input
customPrefix={<KeyRoundIcon size={16} />}
placeholder="Password"
maxWidthClass="w-full"
value={item.password}
onChange={(e) => onChange({ password: e.target.value })}
type="password"
showPasswordToggle
{...INPUT_PROPS}
/>
</div>
)}
{item.type === "bearer" && (
<Input
customPrefix={"Bearer"}
placeholder="e.g. eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
maxWidthClass="w-full"
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
type="password"
showPasswordToggle
{...INPUT_PROPS}
/>
)}
{item.type === "custom" && (
<div className="flex flex-col gap-2">
<Input
customPrefix={<span className="min-w-[38px]">Name</span>}
placeholder="e.g., X-API-Key"
maxWidthClass="w-full"
value={item.header}
onChange={(e) => onChange({ header: e.target.value })}
{...INPUT_PROPS}
/>
<Input
customPrefix={<span className="min-w-[38px]">Value</span>}
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
maxWidthClass="w-full"
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
type="password"
showPasswordToggle
{...INPUT_PROPS}
/>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -23,6 +23,7 @@ import {
} from "@/interfaces/ReverseProxy";
import HelpText from "@components/HelpText";
import Separator from "@components/Separator";
import { isNetBirdHosted } from "@/utils/netbird";
import {
SelectDropdown,
SelectOption,
@@ -99,18 +100,35 @@ export const CustomDomainModal = ({
<div className={"px-8 flex flex-col gap-6 pt-6 pb-8"}>
{availableClusters.length === 0 ? (
<Callout variant="warning">
No proxy clusters are currently connected. Please ensure at least
one proxy is running before adding a domain. <br /> Learn more
about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
isNetBirdHosted() ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please try again in a
few minutes. If the issue persists, check{" "}
<InlineLink
href={"https://status.netbird.io/"}
target={"_blank"}
>
NetBird Status
</InlineLink>{" "}
or reach out to{" "}
<InlineLink href={"mailto:support@netbird.io"}>
support@netbird.io
</InlineLink>
</Callout>
) : (
<Callout variant="warning">
No proxy clusters are currently connected. Please ensure at
least one proxy is running before adding a domain. <br /> Learn
more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
)
) : (
<>
<div>

View File

@@ -22,6 +22,7 @@ import {
} from "@/interfaces/ReverseProxy";
import Paragraph from "@components/Paragraph";
import InlineLink from "@components/InlineLink";
import { isNetBirdHosted } from "@/utils/netbird";
type Props = {
open: boolean;
@@ -79,18 +80,36 @@ export const CustomDomainVerificationModal = ({
</Steps>
<div className={"flex flex-col gap-6"}>
{!cnameTarget ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please ensure at
least one proxy is running to configure DNS verification. <br />
Learn more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
isNetBirdHosted() ? (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please try again in
a few minutes. If the issue persists, check{" "}
<InlineLink
href={"https://status.netbird.io/"}
target={"_blank"}
>
NetBird Status
</InlineLink>{" "}
or reach out to{" "}
<InlineLink href={"mailto:support@netbird.io"}>
support@netbird.io
</InlineLink>
</Callout>
) : (
<Callout variant={"warning"}>
No proxy clusters are currently connected. Please ensure at
least one proxy is running to configure DNS verification.{" "}
<br />
Learn more about{" "}
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Proxy Clusters
<ExternalLinkIcon size={12} />
</InlineLink>
</Callout>
)
) : (
<>
<Card className={"w-full"}>

View File

@@ -0,0 +1,88 @@
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;
subdomainRequired?: boolean;
clusterOffline?: {
clusterName: string;
};
};
export default function ReverseProxyDomainInput({
subdomain,
onSubdomainChange,
baseDomain,
onBaseDomainChange,
domainAlreadyExists,
subdomainRequired = false,
clusterOffline,
}: Readonly<Props>) {
return (
<div>
<Label>Domain</Label>
<HelpText>
{subdomainRequired
? "Enter a subdomain and select a domain for your service."
: "Optionally enter a subdomain, or use the domain directly."}
</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={subdomainRequired ? "myapp" : "myapp (optional)"}
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>
);
}

View File

@@ -0,0 +1,144 @@
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 === d.domain) {
return {
subdomain: "",
baseDomain: d.domain,
isCustom: d.type === ReverseProxyDomainType.CUSTOM,
};
}
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
? `${subdomain}.${baseDomain}`
: 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,
};
}

View File

@@ -1,5 +1,16 @@
import Badge from "@components/Badge";
import { Binary, Mail, RectangleEllipsis, Users } from "lucide-react";
import {
Binary,
FileCode2Icon,
Flag,
GlobeOff,
Mail,
Network,
RectangleEllipsis,
ShieldAlert,
ShieldOff,
Users,
} from "lucide-react";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
@@ -33,6 +44,11 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
icon: <Binary size={12} />,
label: "PIN Code",
};
case "header":
return {
icon: <FileCode2Icon size={12} />,
label: "HTTP Headers",
};
case "link":
case "magic_link":
case "magic-link":
@@ -40,6 +56,41 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
icon: <Mail size={12} />,
label: "Magic Link",
};
case "ip_restricted":
return {
icon: <Network size={12} />,
label: "IP Restricted",
};
case "country_restricted":
return {
icon: <Flag size={12} />,
label: "Country Restricted",
};
case "geo_unavailable":
return {
icon: <GlobeOff size={12} />,
label: "Geo Unavailable",
};
case "crowdsec_ban":
return {
icon: <ShieldAlert size={12} />,
label: "CrowdSec Ban",
};
case "crowdsec_captcha":
return {
icon: <ShieldAlert size={12} />,
label: "CrowdSec Captcha",
};
case "crowdsec_throttle":
return {
icon: <ShieldAlert size={12} />,
label: "CrowdSec Throttle",
};
case "crowdsec_unavailable":
return {
icon: <ShieldOff size={12} />,
label: "CrowdSec Unavailable",
};
default:
return {
icon: null,

View File

@@ -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>
);
};

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import { formatDuration } from "@utils/helpers";
type Props = {
event: ReverseProxyEvent;
@@ -8,7 +9,7 @@ type Props = {
export const ReverseProxyEventsDurationCell = ({ event }: Props) => {
return (
<span className="text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono">
{event.duration_ms}ms
{formatDuration(event.duration_ms)}
</span>
);
};

View File

@@ -19,8 +19,17 @@ export const ReverseProxyEventsLocationIpCell = ({ event }: Props) => {
const { getRegionText, isLoading } = useCountries();
const region = useMemo(() => {
return getRegionText(event.country_code || "", event.city_name || "");
}, [getRegionText, event.country_code, event.city_name]);
return getRegionText(
event.country_code || "",
event.city_name || "",
event.subdivision_code,
);
}, [
getRegionText,
event.country_code,
event.city_name,
event.subdivision_code,
]);
return (
<FullTooltip

View File

@@ -1,11 +1,61 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { ListItem } from "@components/ListItem";
import { Info, ShieldAlert } from "lucide-react";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
const VERDICT_LABELS: Record<string, string> = {
crowdsec_ban: "Ban",
crowdsec_captcha: "Captcha",
crowdsec_throttle: "Throttle",
};
type Props = {
event: ReverseProxyEvent;
};
export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
const metadata = event.metadata;
const verdict = metadata?.crowdsec_verdict;
if (verdict && !event.auth_method_used?.startsWith("crowdsec_")) {
const verdictLabel = VERDICT_LABELS[verdict] ?? verdict;
const metaEntries = Object.entries(metadata!).filter(
([k]) => k !== "crowdsec_verdict",
);
return (
<FullTooltip
side="top"
interactive
delayDuration={250}
skipDelayDuration={100}
disabled={metaEntries.length === 0}
contentClassName="p-0"
content={
<div className="text-xs flex flex-col">
{metaEntries.map(([key, val]) => (
<ListItem
key={key}
icon={<Info size={14} />}
label={key.replaceAll("_", " ")}
value={<span className="text-nb-gray-200">{val}</span>}
/>
))}
</div>
}
>
<div className="px-3 py-2">
<Badge variant="gray" className="gap-1.5">
<ShieldAlert size={12} className="text-yellow-500" />
CrowdSec Observe: {verdictLabel}
</Badge>
</div>
</FullTooltip>
);
}
return (
<span className="text-nb-gray-300 text-[0.82rem] py-2 text-left">
{event.reason || "-"}

View File

@@ -1,13 +1,26 @@
import CopyToClipboardText from "@components/CopyToClipboardText";
import TruncatedText from "@components/ui/TruncatedText";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import {
isL4Event,
ReverseProxy,
ReverseProxyEvent,
} from "@/interfaces/ReverseProxy";
type Props = {
event: ReverseProxyEvent;
service?: ReverseProxy;
};
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 (
<span className="font-mono text-[0.82rem] font-medium py-2 text-nb-gray-300">
{event.method}
@@ -15,8 +28,13 @@ export const ReverseProxyEventsMethodCell = ({ event }: Props) => {
);
};
export const ReverseProxyEventsUrlCell = ({ event }: Props) => {
const fullUrl = `${event.host}${event.path}`;
export const ReverseProxyEventsUrlCell = ({ event, service }: Props) => {
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 (
<TruncatedText
@@ -34,7 +52,12 @@ export const ReverseProxyEventsUrlCell = ({ event }: Props) => {
<CopyToClipboardText message={"URL has been copied to your clipboard"}>
<span className="font-mono text-[0.82rem] whitespace-nowrap">
<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>
</CopyToClipboardText>
</TruncatedText>

View File

@@ -1,12 +1,14 @@
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import { isL4Event, ReverseProxyEvent } from "@/interfaces/ReverseProxy";
import Badge from "@components/Badge";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
event: ReverseProxyEvent;
};
export const ReverseProxyEventsStatusCell = ({ event }: Props) => {
if (isL4Event(event)) return <EmptyRow />;
const isSuccess = event.status_code >= 200 && event.status_code < 400;
return (

View File

@@ -18,8 +18,10 @@ import { DatePickerWithRange } from "@components/DatePickerWithRange";
import { useServerPagination } from "@/contexts/ServerPaginationProvider";
import {
REVERSE_PROXY_EVENTS_DOCS_LINK,
ReverseProxy,
ReverseProxyEvent,
} from "@/interfaces/ReverseProxy";
import useFetchApi from "@/utils/api";
import { ReverseProxyEventsStatusCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsStatusCell";
import { ReverseProxyEventsUserCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsUserCell";
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 { ReverseProxyEventsReasonCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsReasonCell";
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",
header: ({ column }) => (
@@ -73,13 +78,18 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
},
{
id: "url",
accessorFn: (row) => `${row.host} ${row.path}`,
accessorFn: (row) => `${row.host} ${row.path || ""}`,
header: ({ column }) => (
<DataTableHeader column={column} name="url">
URL
<DataTableHeader column={column} name="url" sorting={false}>
Host / URL
</DataTableHeader>
),
cell: ({ row }) => <ReverseProxyEventsUrlCell event={row.original} />,
cell: ({ row }) => (
<ReverseProxyEventsUrlCell
event={row.original}
service={servicesMap.get(row.original.service_id)}
/>
),
},
{
id: "status",
@@ -108,6 +118,16 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
),
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",
accessorKey: "auth_method_used",
@@ -162,6 +182,20 @@ export default function ReverseProxyEventsTable({
...paginationProps
} = 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 dateRange = useMemo<DateRange | undefined>(() => {
@@ -206,7 +240,7 @@ export default function ReverseProxyEventsTable({
text={"Proxy Events"}
sorting={sorting}
setSorting={setSorting}
columns={ReverseProxyEventsTableColumns}
columns={columns}
columnVisibility={{ is_success: false, id: false }}
searchPlaceholder={"Search by IP, host, path, user..."}
getStartedCard={

View File

@@ -0,0 +1,252 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@components/HoverCard";
import {
FlagIcon,
LucideIcon,
NetworkIcon,
Settings,
ShieldAlert,
ShieldCheck,
ShieldOff,
WorkflowIcon,
} from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { useCountries } from "@/contexts/CountryProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
type RuleEntry = {
key: string;
label: string;
Icon: LucideIcon;
value: string;
blocked?: boolean;
};
type Props = {
reverseProxy: ReverseProxy;
};
export default function ReverseProxyAccessControlCell({
reverseProxy,
}: Readonly<Props>) {
const { permission } = usePermissions();
const { openModal, domains } = useReverseProxies();
const { countries } = useCountries();
const canConfigure = !!permission?.services?.update;
const restrictions = reverseProxy.access_restrictions;
const supportsCrowdSec = domains?.find(
(d) => d.domain === reverseProxy.proxy_cluster,
)?.supports_crowdsec;
const hasCrowdSec =
supportsCrowdSec &&
restrictions?.crowdsec_mode != null &&
restrictions.crowdsec_mode !== CrowdSecMode.OFF;
const ruleCount =
(restrictions?.allowed_cidrs?.length ?? 0) +
(restrictions?.blocked_cidrs?.length ?? 0) +
(restrictions?.allowed_countries?.length ?? 0) +
(restrictions?.blocked_countries?.length ?? 0) +
(hasCrowdSec ? 1 : 0);
const rulesBadge =
ruleCount > 0 ? (
<Badge
variant={"gray"}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<ShieldCheck size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>
{ruleCount} {ruleCount === 1 ? "Rule" : "Rules"}
</span>
</Badge>
) : null;
const ruleGroups = useMemo(() => {
const getCountryName = (code: string) => {
const country = countries?.find((c) => c.country_code === code);
return country?.country_name ?? code;
};
const entries: RuleEntry[] = [];
if (restrictions?.allowed_countries?.length) {
entries.push({
key: "allowed-countries",
label: "Allowed Countries",
Icon: FlagIcon,
value: restrictions.allowed_countries.map(getCountryName).join(", "),
});
}
if (restrictions?.blocked_countries?.length) {
entries.push({
key: "blocked-countries",
label: "Blocked Countries",
Icon: FlagIcon,
value: restrictions.blocked_countries.map(getCountryName).join(", "),
blocked: true,
});
}
const isHostCidr = (c: string) =>
c.includes(":") ? c.endsWith("/128") : c.endsWith("/32");
const allowedIps =
restrictions?.allowed_cidrs?.filter(isHostCidr) ?? [];
const allowedCidrs =
restrictions?.allowed_cidrs?.filter((c) => !isHostCidr(c)) ?? [];
const blockedIps =
restrictions?.blocked_cidrs?.filter(isHostCidr) ?? [];
const blockedCidrs =
restrictions?.blocked_cidrs?.filter((c) => !isHostCidr(c)) ?? [];
if (allowedIps.length) {
entries.push({
key: "allowed-ips",
label: allowedIps.length === 1 ? "Allowed IP" : "Allowed IPs",
Icon: WorkflowIcon,
value: allowedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
});
}
if (allowedCidrs.length) {
entries.push({
key: "allowed-cidrs",
label: allowedCidrs.length === 1 ? "Allowed CIDR" : "Allowed CIDRs",
Icon: NetworkIcon,
value: allowedCidrs.join(", "),
});
}
if (blockedIps.length) {
entries.push({
key: "blocked-ips",
label: blockedIps.length === 1 ? "Blocked IP" : "Blocked IPs",
Icon: WorkflowIcon,
value: blockedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
blocked: true,
});
}
if (blockedCidrs.length) {
entries.push({
key: "blocked-cidrs",
label: blockedCidrs.length === 1 ? "Blocked CIDR" : "Blocked CIDRs",
Icon: NetworkIcon,
value: blockedCidrs.join(", "),
blocked: true,
});
}
if (hasCrowdSec) {
entries.push({
key: "crowdsec",
label: "CrowdSec",
Icon: ShieldAlert,
value:
restrictions?.crowdsec_mode === CrowdSecMode.ENFORCE
? "Enforce"
: "Observe",
});
}
return entries;
}, [restrictions, countries, hasCrowdSec]);
const showRulesHover = ruleGroups.length > 0;
return (
<div
className={"flex"}
onClick={(e) => {
e.stopPropagation();
if (permission?.services?.update) {
openModal({ proxy: reverseProxy, initialTab: "access-control" });
}
}}
>
<div className={"flex items-center"}>
{rulesBadge ? (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>{rulesBadge}</HoverCardTrigger>
{showRulesHover && (
<HoverCardContent
className={"p-0"}
sideOffset={14}
onClick={(e) => e.stopPropagation()}
>
<div className={"text-xs"}>
{ruleGroups.map(({ key, label, Icon, value, blocked }) => (
<div
key={key}
className={
"flex justify-between gap-12 py-2 px-4 border-b border-nb-gray-920 last:border-b-0"
}
>
<div
className={
"flex items-start gap-2 font-medium whitespace-nowrap text-nb-gray-100 pt-0.5"
}
>
<Icon
size={14}
className={
blocked ? "text-red-500" : "text-green-500"
}
/>
{label}
</div>
<div
className={"max-w-[200px] text-nb-gray-300 text-right"}
>
{value}
</div>
</div>
))}
</div>
</HoverCardContent>
)}
</HoverCard>
) : (
<Badge
variant={"gray"}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<ShieldOff size={12} className="text-red-500" />
<span className={"font-medium text-xs"}>No Rules</span>
</Badge>
)}
<Button
size={"xs"}
variant={"secondary"}
className={"!rounded-l-none !px-3 !h-[34px]"}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "access-control" });
}}
disabled={!permission?.services?.update}
aria-label="Configure access control"
>
<Settings size={12} />
</Button>
</div>
</div>
);
}

View File

@@ -37,6 +37,7 @@ export default function ReverseProxyActionCell({
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
data-proxy-edit-action={reverseProxy.id}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy });

View File

@@ -15,7 +15,7 @@ export default function ReverseProxyActiveCell({
const { handleToggle } = useReverseProxies();
return (
<div className={"flex min-w-[0px]"}>
<div className={"flex min-w-[0px]"} data-active-cell>
<ToggleSwitch
disabled={!permission?.services?.update}
checked={reverseProxy.enabled}

View File

@@ -11,11 +11,13 @@ import { UserCountStack } from "@components/ui/MultipleGroups";
import {
ArrowRightIcon,
Binary,
FileCode2Icon,
HelpCircle,
LockKeyhole,
LockOpenIcon,
LucideIcon,
RectangleEllipsis,
Settings,
ShieldCheck,
ShieldOff,
Users,
} from "lucide-react";
import * as React from "react";
@@ -23,7 +25,8 @@ import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { Group } from "@/interfaces/Group";
import { ReverseProxy } from "@/interfaces/ReverseProxy";
import { isL4Mode, ReverseProxy } from "@/interfaces/ReverseProxy";
import FullTooltip from "@components/FullTooltip";
const AUTH_METHODS: {
key: "password_auth" | "pin_auth" | "bearer_auth";
@@ -46,6 +49,12 @@ const AUTH_METHODS: {
},
];
const HEADER_AUTH_METHOD = {
label: "HTTP Headers",
hoverLabel: "HTTP Headers",
Icon: FileCode2Icon,
};
type Props = {
reverseProxy: ReverseProxy;
};
@@ -56,9 +65,32 @@ export default function ReverseProxyAuthCell({
const { permission } = usePermissions();
const { openModal } = useReverseProxies();
const { groups } = useGroups();
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 enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
const hasHeaderAuths = (auth?.header_auths ?? []).some((h) => h.enabled);
const authCount = enabled.length + (hasHeaderAuths ? 1 : 0);
const ssoGroups = auth?.bearer_auth?.enabled
? (auth.bearer_auth.distribution_groups ?? [])
@@ -66,108 +98,149 @@ export default function ReverseProxyAuthCell({
.filter((g): g is Group => g != undefined)
: [];
const showHoverContent =
enabled.length > 1 || (enabled.length === 1 && auth?.bearer_auth?.enabled);
const canConfigure = !!permission?.services?.update;
const singleAuth =
authCount === 1
? enabled.length === 1
? enabled[0]
: HEADER_AUTH_METHOD
: null;
const SingleAuthIcon = singleAuth?.Icon ?? null;
const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null;
const authBadge = SingleAuthIcon ? (
<Badge
variant={"gray"}
useHover={false}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<SingleAuthIcon size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>{singleAuth!.label}</span>
</Badge>
) : authCount > 1 ? (
<Badge
variant={"gray"}
useHover={false}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<LockKeyhole size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>{authCount} Enabled</span>
</Badge>
) : null;
const badgeContent =
SingleIcon ? (
<>
<SingleIcon size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
</>
) : enabled.length > 1 ? (
<>
<ShieldCheck size={12} className="text-green-400" />
<span className={"font-medium text-xs"}>
{enabled.length} Enabled
</span>
</>
) : null;
const showAuthHover =
authCount > 1 ||
(authCount === 1 && (auth?.bearer_auth?.enabled || hasHeaderAuths));
return (
<div
className={"flex gap-3"}
className={"flex"}
data-auth-cell
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "auth" });
if (permission?.services?.update) {
openModal({ proxy: reverseProxy, initialTab: "auth" });
}
}}
>
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
{badgeContent ? (
<Badge
variant={"gray"}
useHover={false}
className={"cursor-pointer"}
>
{badgeContent}
</Badge>
) : (
<Badge variant={"gray"}>
<ShieldOff size={12} className="text-red-500" />
<span className={"font-medium text-xs"}>None</span>
</Badge>
)}
</HoverCardTrigger>
{showHoverContent && (
<HoverCardContent
className={"p-0"}
sideOffset={14}
onClick={(e) => e.stopPropagation()}
>
<div className={"text-xs"}>
{enabled.map(({ key, hoverLabel, Icon }) => (
<ListItem
key={key}
className={"py-0.5"}
icon={<Icon size={14} />}
label={hoverLabel}
value={
<div className={"text-green-500"}>
{key === "bearer_auth" && ssoGroups.length === 0
? "All Users"
: "Enabled"}
</div>
}
>
{key === "bearer_auth" && ssoGroups.length > 0 && (
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
{ssoGroups.map((group) => (
<div
key={group.id}
className={
"flex gap-2 items-center justify-between"
}
>
<GroupBadge group={group} />
<ArrowRightIcon size={14} />
<UserCountStack group={group} />
<div className={"flex items-center"}>
{authBadge ? (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>{authBadge}</HoverCardTrigger>
{showAuthHover && (
<HoverCardContent
className={"p-0"}
sideOffset={14}
onClick={(e) => e.stopPropagation()}
>
<div className={"text-xs"}>
{enabled.map(({ key, hoverLabel, Icon }) => (
<ListItem
key={key}
className={"py-0.5"}
icon={<Icon size={14} />}
label={hoverLabel}
value={
<div className={"text-green-500"}>
{key === "bearer_auth" && ssoGroups.length === 0
? "All Users"
: "Enabled"}
</div>
))}
</div>
}
>
{key === "bearer_auth" && ssoGroups.length > 0 && (
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
{ssoGroups.map((group) => (
<div
key={group.id}
className={
"flex gap-2 items-center justify-between"
}
>
<GroupBadge group={group} />
<ArrowRightIcon size={14} />
<UserCountStack group={group} />
</div>
))}
</div>
)}
</ListItem>
))}
{hasHeaderAuths && (
<ListItem
className={"py-0.5"}
icon={<FileCode2Icon size={14} />}
label={HEADER_AUTH_METHOD.hoverLabel}
value={
<div className={"text-green-500"}>
{
(auth?.header_auths ?? []).filter((h) => h.enabled)
.length
}{" "}
Header
{(auth?.header_auths ?? []).filter((h) => h.enabled)
.length !== 1
? "s"
: ""}
</div>
}
/>
)}
</ListItem>
))}
</div>
</HoverCardContent>
</div>
</HoverCardContent>
)}
</HoverCard>
) : (
<Badge
variant={"gray"}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<LockOpenIcon size={12} className="text-red-500" />
<span className={"font-medium text-xs"}>No Auth</span>
</Badge>
)}
</HoverCard>
<Button
size={"xs"}
variant={"secondary"}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "auth" });
}}
className={"!px-3"}
disabled={!permission?.services?.update}
>
<Settings size={12} />
Configure
</Button>
<Button
size={"xs"}
variant={"secondary"}
className={"!rounded-l-none !px-3 !h-[34px]"}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "auth" });
}}
disabled={!permission?.services?.update}
aria-label="Configure authentication"
>
<Settings size={12} />
</Button>
</div>
</div>
);
}

View File

@@ -10,6 +10,8 @@ import {
ReverseProxyDomainType,
} from "@/interfaces/ReverseProxy";
import FullTooltip from "@components/FullTooltip";
import { isNetBirdHosted } from "@/utils/netbird";
import InlineLink from "@components/InlineLink";
type Props = {
reverseProxy: ReverseProxy;
@@ -29,7 +31,7 @@ export default function ReverseProxyClusterCell({
if (!hasCluster) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-cluster-cell>
<Badge variant="gray" className="font-normal">
<Globe size={12} />
All
@@ -40,7 +42,7 @@ export default function ReverseProxyClusterCell({
if (isConnected) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-cluster-cell>
<Badge variant={"gray"} className={cn("font-normal")}>
<Server size={11} className={cn("text-green-500")} />
{reverseProxy.proxy_cluster}
@@ -52,15 +54,29 @@ export default function ReverseProxyClusterCell({
return (
<FullTooltip
content={
<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>
isNetBirdHosted() ? (
<div className={"text-xs max-w-xs"}>
Cluster {reverseProxy.proxy_cluster} 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>
</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"}
alignOffset={0}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-cluster-cell>
<Badge variant={"red"} className={cn("font-normal")}>
<AlertTriangle size={11} />
{reverseProxy.proxy_cluster}

View File

@@ -1,8 +1,9 @@
import { cn } from "@utils/helpers";
import { ChevronDown, ChevronRightIcon, LockIcon } from "lucide-react";
import * as React from "react";
import { ReverseProxy } from "@/interfaces/ReverseProxy";
import { isL4Mode, ReverseProxy, ServiceMode } from "@/interfaces/ReverseProxy";
import ExternalLinkText from "@components/ExternalLinkText";
import CopyToClipboardText from "@components/CopyToClipboardText";
type Props = {
reverseProxy?: ReverseProxy;
@@ -18,8 +19,14 @@ export default function ReverseProxyNameCell({
showChevron = true,
}: Readonly<Props>) {
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 hasTargets = (reverseProxy?.targets?.length ?? 0) > 0;
const hasExpandableTargets =
(reverseProxy?.targets?.length ?? 0) > 0 && !isL4Mode(reverseProxy?.mode);
return (
<div
@@ -29,6 +36,7 @@ export default function ReverseProxyNameCell({
? "gap-6 min-w-[270px] max-w-[270px]"
: "gap-2.5 min-w-[200px]",
)}
data-name-cell
>
{showChevron && (
<>
@@ -36,14 +44,14 @@ export default function ReverseProxyNameCell({
size={20}
className={cn(
"group-data-[accordion=opened]/accordion:hidden text-nb-gray-400 shrink-0",
!hasTargets && "cursor-default opacity-0",
!hasExpandableTargets && "cursor-default opacity-0",
)}
/>
<ChevronDown
size={20}
className={cn(
"group-data-[accordion=closed]/accordion:hidden text-nb-gray-400 shrink-0",
!hasTargets && "cursor-default opacity-0",
!hasExpandableTargets && "cursor-default opacity-0",
)}
/>
</>
@@ -57,13 +65,21 @@ export default function ReverseProxyNameCell({
)}
/>
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 truncate">
{displayDomain ? (
<ExternalLinkText href={`https://${displayDomain}`}>
<span className="font-medium truncate">{displayDomain}</span>
</ExternalLinkText>
) : (
<span className="font-medium truncate">{displayDomain}</span>
)}
<div className="flex items-center gap-2">
{displayDomain && isLinkable ? (
<ExternalLinkText href={`https://${displayDomain}${portSuffix}`}>
<span className="font-medium truncate">
{displayDomain}
{portSuffix}
</span>
</ExternalLinkText>
) : (
<CopyToClipboardText>
{displayDomain}
{portSuffix}
</CopyToClipboardText>
)}
</div>
</div>
</div>
</div>

View File

@@ -1,17 +1,21 @@
import {
REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK,
ReverseProxy,
ReverseProxyMeta,
ReverseProxyStatus,
} from "@/interfaces/ReverseProxy";
import useFetchApi from "@utils/api";
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";
type Props = {
serviceId: string;
meta?: ReverseProxyMeta;
enabled?: boolean;
isL4?: boolean;
};
const POLL_INTERVAL_MS = 3500;
@@ -20,6 +24,7 @@ export default function ReverseProxyStatusCell({
serviceId,
meta,
enabled,
isL4,
}: Readonly<Props>) {
const dataRef = useRef<ReverseProxy | undefined>(undefined);
@@ -27,11 +32,19 @@ export default function ReverseProxyStatusCell({
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 =
!!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>(
`/reverse-proxies/services/${serviceId}`,
@@ -43,13 +56,80 @@ export default function ReverseProxyStatusCell({
dataRef.current = data;
if (!enabled || (isActive && certificateIssued)) {
return null;
if (!enabled) return null;
// L4 services don't need certificates
if (isL4) {
if (isActive) return null;
if (hasError) {
return (
<div className={"flex"} data-status-cell>
<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>
</div>
);
}
if (isTunnelNotCreated) {
return (
<div className={"flex"} data-status-cell>
<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>
</div>
);
}
return <SettingUpService />;
}
// HTTP services: hide once active with certificate issued
if (isActive && certificateIssued) {
return <div data-status-cell />;
}
if (!certificateIssued) {
return (
<div className={"flex"}>
<div className={"flex"} data-status-cell>
<Badge variant={"yellow"}>
<Loader2 size={12} className={"animate-spin"} />
Issuing certificate...
@@ -58,12 +138,16 @@ export default function ReverseProxyStatusCell({
);
}
return <SettingUpService />;
}
const SettingUpService = () => {
return (
<div className={"flex"}>
<div className={"flex"} data-status-cell>
<Badge variant={"yellow"}>
<Loader2 size={14} className={"animate-spin"} />
Setting up service...
</Badge>
</div>
);
}
};

View File

@@ -17,17 +17,20 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import {
isL4Mode,
REVERSE_PROXY_DOCS_LINK,
ReverseProxy,
} from "@/interfaces/ReverseProxy";
import ReverseProxyActionCell from "@/modules/reverse-proxy/table/ReverseProxyActionCell";
import ReverseProxyActiveCell from "@/modules/reverse-proxy/table/ReverseProxyActiveCell";
import ReverseProxyAccessControlCell from "@/modules/reverse-proxy/table/ReverseProxyAccessControlCell";
import ReverseProxyAuthCell from "@/modules/reverse-proxy/table/ReverseProxyAuthCell";
import ReverseProxyClusterCell from "@/modules/reverse-proxy/table/ReverseProxyClusterCell";
import ReverseProxyNameCell from "@/modules/reverse-proxy/table/ReverseProxyNameCell";
import ReverseProxyTargetsCell from "@/modules/reverse-proxy/table/ReverseProxyTargetsCell";
import ReverseProxyTargetsTable from "@/modules/reverse-proxy/targets/ReverseProxyTargetsTable";
import ReverseProxyStatusCell from "@/modules/reverse-proxy/table/ReverseProxyStatusCell";
import { ReverseProxyTypeCell } from "@/modules/reverse-proxy/table/ReverseProxyTypeCell";
const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
{
@@ -38,6 +41,14 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
sortingFn: "text",
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",
accessorFn: (proxy) => proxy?.meta?.certificate_issued_at,
@@ -48,6 +59,7 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
serviceId={row.original.id}
meta={row.original.meta}
enabled={row.original.enabled}
isL4={isL4Mode(row.original.mode)}
/>
) : null,
},
@@ -68,7 +80,7 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
{
accessorKey: "targets",
header: ({ column }) => {
return <DataTableHeader column={column}>Targets</DataTableHeader>;
return <DataTableHeader column={column}>Target(s)</DataTableHeader>;
},
cell: ({ row }) => <ReverseProxyTargetsCell reverseProxy={row.original} />,
},
@@ -79,6 +91,17 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
},
cell: ({ row }) => <ReverseProxyAuthCell reverseProxy={row.original} />,
},
{
id: "access_rules",
header: ({ column }) => {
return (
<DataTableHeader column={column}>Access Control</DataTableHeader>
);
},
cell: ({ row }) => (
<ReverseProxyAccessControlCell reverseProxy={row.original} />
),
},
{
accessorKey: "id",
header: "",
@@ -131,7 +154,9 @@ export default function ReverseProxyTable({ headingTarget }: Readonly<Props>) {
useRowId={true}
searchPlaceholder={"Search by URL, domain, or target..."}
columnVisibility={{ searchString: false }}
tableCellClassName={"h-[80px]"}
renderExpandedRow={(reverseProxy) => {
if (isL4Mode(reverseProxy.mode)) return;
const hasTargets = (reverseProxy?.targets?.length ?? 0) > 0;
if (!hasTargets) return;
return (
@@ -159,7 +184,6 @@ export default function ReverseProxyTable({ headingTarget }: Readonly<Props>) {
button={
<Button
variant={"primary"}
className={""}
onClick={() => openModal()}
disabled={!permission?.services?.create}
>

View File

@@ -4,7 +4,8 @@ import { PlusCircle, Server } from "lucide-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
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 = {
reverseProxy: ReverseProxy;
@@ -16,10 +17,26 @@ export default function ReverseProxyTargetsCell({
const { permission } = usePermissions();
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;
return (
<div className={"flex gap-3"}>
<div className={"flex gap-3"} data-targets-cell>
{targetsCount > 0 && (
<Badge
variant={"gray"}

View 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 "HTTPS";
const mode = SERVICE_MODES[reverseProxy.mode];
if (!mode) return "HTTPS";
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)} />;
}
};

View 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}
/>
);
}

View File

@@ -17,11 +17,19 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
target: ReverseProxyTarget;
showDescription?: boolean;
wrapperClassName?: string;
skeletonClassName?: string;
deviceClassName?: string;
address?: string;
};
export const ReverseProxyTargetDevice = ({
target,
showDescription,
wrapperClassName = "h-[59px]",
skeletonClassName = "min-h-[59px]",
deviceClassName = "",
address,
}: Props) => {
const router = useRouter();
const { data: peers, isLoading: isPeersLoading } =
@@ -55,17 +63,19 @@ export const ReverseProxyTargetDevice = ({
};
if (isPeersLoading || isResourceLoading || isNetworksLoading)
return <SkeletonDeviceCard />;
return <SkeletonDeviceCard className={skeletonClassName} />;
if (!peer && !resource)
return (
<div className={"min-h-[59px] flex items-center relative left-1"}>
<div
className={cn("flex items-center relative left-1", wrapperClassName)}
>
<EmptyRow />
</div>
);
return (
<div className={"min-h-[59px] flex items-center relative -left-2"}>
<div className={cn("flex items-center relative -left-2", wrapperClassName)}>
<div
className={cn(
"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}
>
<DeviceCard
address={address}
device={peer}
className={cn(!target.enabled && "opacity-40", "pl-2")}
className={cn(
!target.enabled && "opacity-40",
"pl-2",
deviceClassName,
)}
resource={resource}
description={showDescription ? resource?.description : undefined}
/>

View File

@@ -1,5 +1,11 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@components/Accordion";
import Button from "@components/Button";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import HelpText from "@components/HelpText";
@@ -7,22 +13,16 @@ import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { SelectDropdown } from "@components/select/SelectDropdown";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import useFetchApi from "@utils/api";
import {
AlertTriangle,
ClockFadingIcon,
ExternalLinkIcon,
PlusCircle,
Server,
Settings,
ShieldXIcon,
Text,
} from "lucide-react";
import { Callout } from "@components/Callout";
import cidr from "ip-cidr";
import React, { useMemo, useRef, useState } from "react";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
@@ -32,6 +32,7 @@ import {
ReverseProxyTarget,
ReverseProxyTargetProtocol,
ReverseProxyTargetType,
ServiceMode,
ServiceTargetOptionsPathRewrite,
} from "@/interfaces/ReverseProxy";
import {
@@ -40,11 +41,18 @@ import {
} from "@/contexts/ReverseProxiesProvider";
import { cn } from "@utils/helpers";
import { HelpTooltip } from "@components/HelpTooltip";
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
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 ReverseProxyAddressInput, {
CidrHelpText,
useReverseProxyAddress,
} from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
import Separator from "@components/Separator";
/** Get initial host value based on target, resource, or peer */
function getInitialHost(
@@ -85,38 +93,33 @@ export default function ReverseProxyTargetModal({
}: Readonly<Props>) {
const existingTargets = reverseProxy.targets || [];
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] =
useState<ReverseProxyTargetProtocol>(
currentTarget?.protocol ?? ReverseProxyTargetProtocol.HTTP,
);
const [targetHost, setTargetHost] = useState(
getInitialHost(currentTarget, initialResource, initialPeer),
);
const [targetPort, setTargetPort] = useState<number>(
currentTarget?.port ?? 0,
);
@@ -125,50 +128,9 @@ export default function ReverseProxyTargetModal({
const [options, setOption, { getTargetOptions, headers, errors }] =
useReverseProxyTargetOptions(currentTarget?.options);
const portInputRef = useRef<HTMLInputElement>(null);
const [installModal, setInstallModal] = useState(false);
// Get the current resource's address (from initialResource or selected resource)
const currentResourceAddress = useMemo(() => {
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]);
const { isCidrRange, isHostEditable, isValidCidrHost } =
useReverseProxyAddress(target);
// Normalize path for comparison (ensure it starts with / and handle empty as /)
const normalizePath = (path: string | undefined) => {
@@ -196,7 +158,6 @@ export default function ReverseProxyTargetModal({
const isValidPort =
targetPort === 0 || (targetPort >= 1 && targetPort <= 65535);
const isValidCidrHost = !isCidrRange || (targetHost && isHostInCidrRange);
const canAddTarget = useMemo(() => {
// Don't allow if path is duplicate or port is invalid
@@ -209,11 +170,12 @@ export default function ReverseProxyTargetModal({
if (initialPeer) {
return true;
}
if (targetType === ReverseProxyTargetType.PEER) {
return !!targetPeerId;
if (!target) return false;
if (target.type === ReverseProxyTargetType.PEER) {
return !!target.peerId;
}
if (isResourceTargetType(targetType)) {
return !!targetResourceId && isValidCidrHost;
if (isResourceTargetType(target.type)) {
return !!target.resourceId && isValidCidrHost;
}
return false;
}, [
@@ -221,28 +183,30 @@ export default function ReverseProxyTargetModal({
isValidPort,
initialResource,
initialPeer,
targetType,
targetPeerId,
targetResourceId,
target,
isValidCidrHost,
]);
const hasTarget =
initialResource || initialPeer || targetPeerId || targetResourceId;
const hasTarget = !!(initialResource || initialPeer || target);
const handleSave = () => {
const resolvedType = initialPeer ? ReverseProxyTargetType.PEER : targetType;
if (!target) return;
const resolvedType = initialPeer
? ReverseProxyTargetType.PEER
: target.type;
const resolvedIsResource =
isResourceTargetType(resolvedType) || !!initialResource;
const targetData: ReverseProxyTarget = {
target_type: resolvedType,
target_id:
resolvedType === ReverseProxyTargetType.PEER
? targetPeerId
: targetResourceId,
? target.peerId
: target.resourceId,
protocol: targetProtocol,
host:
resolvedType === ReverseProxyTargetType.SUBNET ? targetHost : undefined,
resolvedType === ReverseProxyTargetType.SUBNET
? target.host
: undefined,
port: targetPort,
path: targetPath || undefined,
enabled: currentTarget?.enabled ?? true,
@@ -264,397 +228,291 @@ export default function ReverseProxyTargetModal({
color="netbird"
/>
<Tabs value={tab} onValueChange={setTab}>
<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>
<Separator />
<TabsContent value={"details"} className={"pb-8"}>
<div className="px-8 flex flex-col gap-8">
{!initialResource && !initialPeer && (
<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&apos;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&apos;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&apos;t have resources yet, go to{" "}
<InlineLink href={"/networks"}>
Networks
</InlineLink>{" "}
to create some.
</span>
</>
}
interactive={true}
>
Resource
</HelpTooltip>
</>
)}
</Label>
<div className="px-8 pt-5 pb-4 flex flex-col gap-6">
{!initialResource && !initialPeer && (
<ReverseProxyTargetSelector
value={target}
initialNetwork={initialNetwork}
onChange={(selection) => {
setTarget(selection);
if (selection) {
setTimeout(() => portInputRef.current?.focus(), 0);
}
}}
/>
)}
<HelpText>
{initialNetwork
? "Select the resource from your network you want to expose."
: "Select the peer where your service is running or select a resource to expose it."}
</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={
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>
<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>
<div>
<div className="flex gap-3 mt-1">
<div className="flex-1">
<Label>Protocol & Host / IP</Label>
{cidrInfo && (
<HelpText className="!mt-1">
Enter an IP address within {currentResourceAddress}
</HelpText>
)}
<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">
<Input
value={targetHost}
onChange={(e) => {
// Only allow valid IP characters for CIDR ranges
const value = isHostEditable
? e.target.value.replace(/[^0-9.]/g, "")
: e.target.value;
setTargetHost(value);
}}
placeholder="e.g., 192.168.0.10"
className="!rounded-l-none"
disabled={!hasTarget}
readOnly={
hasTarget && !isHostEditable ? true : undefined
}
autoFocus={!!initialResource && isHostEditable}
/>
</div>
</div>
</div>
<div className="w-[150px]">
<Label>
Port
<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={
"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">&nbsp;</HelpText>
)}
<div className="mt-2">
<Input
ref={portInputRef}
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}
/>
</>
}
helpText={
<div>
Keep the original full request path when forwarding.{" "}
<br />
When disabled the matched prefix path is stripped.
</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>
{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>
</TabsContent>
<TabsContent value={"options"} className={"pb-8"}>
<div className="px-8 flex flex-col gap-8 pt-1.5">
<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 className="w-[150px]">
<Label>
Port
<HelpTooltip
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."
}
/>
</Label>
<div className="mt-2">
<Input
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>
<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>
<ReverseProxyTargetCustomHeaders {...headers} />
</div>
</TabsContent>
</Tabs>
{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>
<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
(e.g. 30s, 2m). <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
(e.g., 30s, 2m). <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"}>
<div className={"w-full"}>
@@ -670,69 +528,27 @@ export default function ReverseProxyTargetModal({
</Paragraph>
</div>
<div className="flex gap-3 w-full justify-end">
{currentTarget ? (
<>
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={!canAddTarget || errors.options}
>
Save Changes
</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>
</>
)}
</>
)}
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={!canAddTarget || errors.options}
>
{currentTarget ? (
"Save Changes"
) : (
<>
<PlusCircle size={16} />
Add Target
</>
)}
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={installModal} onOpenChange={setInstallModal}>
<SetupModal />
</Modal>
</>
);
}

View 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&apos;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&apos;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&apos;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>
);
}

View File

@@ -74,6 +74,7 @@ export default function ReverseProxyTargetsTable({ reverseProxy }: Props) {
className={"bg-nb-gray-960 py-2"}
inset={true}
text={"Targets"}
initialPageSize={reverseProxy?.targets?.length}
manualPagination={true}
sorting={sorting}
columnVisibility={{}}

View File

@@ -9,7 +9,7 @@ import { MoreVertical, Settings, SquarePenIcon, Trash2 } from "lucide-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
import { isL4Mode, ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
type Props = {
target: ReverseProxyFlatTarget;
@@ -38,9 +38,14 @@ export default function ReverseProxyFlatTargetActionCell({
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
data-proxy-edit-action={target.proxy.id}
onClick={(e) => {
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}
>
@@ -51,15 +56,16 @@ export default function ReverseProxyFlatTargetActionCell({
</DropdownMenuItem>
<DropdownMenuItem
data-proxy-settings-action={target.proxy.id}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: target.proxy, initialTab: "settings" });
}}
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
Advanced Settings
</div>
</DropdownMenuItem>

View File

@@ -14,6 +14,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
import ReverseProxyArrowCell from "@/modules/reverse-proxy/table/ReverseProxyArrowCell";
import ReverseProxyAccessControlCell from "@/modules/reverse-proxy/table/ReverseProxyAccessControlCell";
import ReverseProxyAuthCell from "@/modules/reverse-proxy/table/ReverseProxyAuthCell";
import ReverseProxyClusterCell from "@/modules/reverse-proxy/table/ReverseProxyClusterCell";
import ReverseProxyDestinationCell from "@/modules/reverse-proxy/table/ReverseProxyDestinationCell";
@@ -44,16 +45,23 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
: `/${target.path}`
: "";
const fullUrl = `${target.proxy.domain}${path}`;
const disabled = target.enabled === false;
const isEnabled = target.proxy.enabled && target.enabled !== false;
const disabled = !target.enabled;
const isEnabled = target.proxy.enabled && target.enabled;
return (
<div className={disabled ? "opacity-40" : ""}>
<ReverseProxyNameCell
domain={fullUrl}
enabled={isEnabled}
showChevron={false}
/>
<div className="flex items-center gap-2">
<div
className={disabled ? "opacity-40" : ""}
data-proxy-id={target.proxy.id}
>
<ReverseProxyNameCell
domain={fullUrl}
enabled={isEnabled}
reverseProxy={row.original.proxy}
showChevron={false}
/>
</div>
<div data-status-cell />
</div>
);
},
@@ -62,7 +70,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
accessorKey: "arrow",
header: "",
cell: ({ row }) => (
<ReverseProxyArrowCell disabled={row.original.enabled === false} />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyArrowCell disabled={row.original.enabled === false} />
</div>
),
},
{
@@ -70,7 +80,11 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
header: ({ column }) => (
<DataTableHeader column={column}>Destination</DataTableHeader>
),
cell: ({ row }) => <ReverseProxyDestinationCell target={row.original} />,
cell: ({ row }) => (
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyDestinationCell target={row.original} />
</div>
),
},
{
accessorKey: "enabled",
@@ -78,9 +92,11 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
<DataTableHeader column={column}>Active</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyTargetProvider value={row.original.proxy}>
<ReverseProxyTargetActiveCell target={row.original} />
</ReverseProxyTargetProvider>
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyTargetProvider value={row.original.proxy}>
<ReverseProxyTargetActiveCell target={row.original} />
</ReverseProxyTargetProvider>
</div>
),
},
{
@@ -89,7 +105,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
<DataTableHeader column={column}>Cluster</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyClusterCell reverseProxy={row.original.proxy} />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyClusterCell reverseProxy={row.original.proxy} />
</div>
),
},
{
@@ -98,7 +116,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
<DataTableHeader column={column}>Resource</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyTargetDevice target={row.original} showDescription />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyTargetDevice target={row.original} showDescription />
</div>
),
},
{
@@ -107,7 +127,20 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
<DataTableHeader column={column}>Auth Methods</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyAuthCell reverseProxy={row.original.proxy} />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyAuthCell reverseProxy={row.original.proxy} />
</div>
),
},
{
id: "access_control",
header: ({ column }) => (
<DataTableHeader column={column}>Access Control</DataTableHeader>
),
cell: ({ row }) => (
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyAccessControlCell reverseProxy={row.original.proxy} />
</div>
),
},
{
@@ -120,7 +153,13 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
{
id: "searchString",
accessorFn: (row) => {
return [row.proxy.domain, row.destination, row.host, row.port, row.path].join("");
return [
row.proxy.domain,
row.destination,
row.host,
row.port,
row.path,
].join("");
},
},
];

View File

@@ -7,33 +7,20 @@ import {
// Go time.ParseDuration format: one or more {number}{unit} pairs
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
function parseDurationMs(duration: string): number {
const units: Record<string, number> = {
ns: 1e-6,
us: 1e-3,
µs: 1e-3,
ms: 1,
s: 1000,
m: 60_000,
h: 3_600_000,
};
let total = 0;
for (const [, val, , unit] of duration.matchAll(
/(\d+(\.\d+)?)(ns|us|µs|ms|s|m|h)/g,
)) {
total += parseFloat(val) * units[unit];
}
return total;
}
function validateTimeout(timeout: string): string | undefined {
export function validateTimeout(timeout: string): string | undefined {
if (!timeout) return undefined;
if (!DURATION_RE.test(timeout))
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
if (parseDurationMs(timeout) > MAX_TIMEOUT_MS)
return "Timeout cannot exceed the maximum of 5m.";
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"';
return undefined;
}
@@ -67,7 +54,11 @@ export function useReverseProxyTargetOptions(
);
const timeoutError = validateTimeout(targetOptions.request_timeout ?? "");
const hasOptionsErrors = !!timeoutError || hasHeaderErrors;
const sessionIdleTimeoutError = validateSessionIdleTimeout(
targetOptions.session_idle_timeout ?? "",
);
const hasOptionsErrors =
!!timeoutError || !!sessionIdleTimeoutError || hasHeaderErrors;
const getTargetOptions = useCallback((): ServiceTargetOptions | undefined => {
const customHeaders = headerEntriesToRecord(headerEntries);
@@ -94,6 +85,7 @@ export function useReverseProxyTargetOptions(
},
errors: {
timeout: timeoutError,
sessionIdleTimeout: sessionIdleTimeoutError,
options: hasOptionsErrors,
},
},

View File

@@ -174,7 +174,7 @@ export default function NetworkRoutesTable({
wrapperComponent={isGroupPage ? Card : undefined}
wrapperProps={isGroupPage ? { className: "mt-6 w-full" } : undefined}
paginationPaddingClassName={isGroupPage ? "px-0 pt-8" : undefined}
tableClassName={isGroupPage ? "mt-0 mb-2" : undefined}
tableClassName={isGroupPage ? "mt-0" : undefined}
inset={false}
minimal={isGroupPage}
keepStateInLocalStorage={!isGroupPage}

View File

@@ -11,6 +11,7 @@ import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { Callout } from "@components/Callout";
import { useHasChanges } from "@hooks/useHasChanges";
import * as Tabs from "@radix-ui/react-tabs";
import { useApiCall } from "@utils/api";
@@ -20,6 +21,7 @@ import {
ExternalLinkIcon,
FlaskConicalIcon,
MonitorSmartphoneIcon,
AlertTriangle,
RefreshCcw,
} from "lucide-react";
import React, { useMemo, useState } from "react";
@@ -84,6 +86,10 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
isCustomVersion ? autoUpdateSetting : "",
);
const [autoUpdateAlways, setAutoUpdateAlways] = useState(
account.settings?.auto_update_always ?? false,
);
const [peerExposeEnabled, setPeerExposeEnabled] = useState<boolean>(
account?.settings?.peer_expose_enabled ?? false,
);
@@ -99,6 +105,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
const { hasChanges, updateRef } = useHasChanges([
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
peerExposeEnabled,
peerExposeGroupNames,
]);
@@ -155,6 +162,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
settings: {
...account.settings,
auto_update_version: autoUpdateCustomVersion || autoUpdateMethod,
auto_update_always: autoUpdateAlways,
peer_expose_enabled: peerExposeEnabled,
peer_expose_groups: peerExposeGroupIds,
},
@@ -164,6 +172,7 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
updateRef([
autoUpdateMethod,
autoUpdateCustomVersion,
autoUpdateAlways,
peerExposeEnabled,
peerExposeGroupNames,
]);
@@ -235,9 +244,9 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
/>
</Label>
<HelpText>
Select how NetBird clients handle automatic updates by choosing
the latest version, a custom version, or disabling updates
altogether. Automatic Updates require at least NetBird{" "}
Configure how NetBird clients receive update notifications.
When enabled, users will be prompted to install the selected
version. This requires at least NetBird{" "}
<span className={"text-white font-medium"}>v0.61.0</span>.{" "}
<InlineLink
href={"https://docs.netbird.io/manage/peers/auto-update"}
@@ -265,6 +274,39 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
}}
/>
</div>
<FancyToggleSwitch
className={"mt-4"}
value={autoUpdateAlways}
onChange={setAutoUpdateAlways}
label={
<>
<AlertTriangle size={15} className={"text-yellow-400"} />
Force Automatic Updates
</>
}
helpText={
"When enabled, updates are installed automatically in the background without user interaction."
}
disabled={
!permission.settings.update || autoUpdateMethod === "disabled"
}
/>
{autoUpdateAlways && autoUpdateMethod !== "disabled" && (
<Callout
className={"mt-3"}
variant={"warning"}
icon={
<AlertTriangle
size={14}
className={"shrink-0 relative top-[3px]"}
/>
}
>
Enabling automatic updates will restart the NetBird client
during updates, which can temporarily disrupt active
connections. Use with caution in production environments.
</Callout>
)}
</div>
<div>

View File

@@ -26,9 +26,8 @@ export default function DangerZoneTab({ account }: Props) {
const deletePromise = new Promise<void>((resolve, reject) => {
return deleteRequest
.del()
.catch((error) => reject(error))
.then(() => {
// Clear browser storage after account deletion
// Clear browser storage only after confirmed account deletion
if (typeof window !== "undefined") {
localStorage.clear();
sessionStorage.clear();
@@ -37,7 +36,8 @@ export default function DangerZoneTab({ account }: Props) {
}
logout().then();
resolve();
});
})
.catch((error) => reject(error));
});
notify({

View File

@@ -6,13 +6,22 @@ type Props = {
name?: string;
email?: string;
id?: string;
size?: "default" | "sm";
className?: string;
};
export const SmallUserAvatar = ({ name, id, email, className }: Props) => {
export const SmallUserAvatar = ({
name,
id,
email,
size = "default",
className,
}: Props) => {
return (
<div
className={cn(
"w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-white uppercase text-[12px] font-medium bg-nb-gray-850",
"rounded-full shrink-0 flex items-center justify-center text-white uppercase font-medium bg-nb-gray-850",
size === "default" && "w-7 h-7 text-[12px]",
size === "sm" && "w-5 h-5 text-[9px] leading-[0]",
className,
)}
style={{

View File

@@ -257,3 +257,16 @@ export const singularize = (
}
return count + " " + word;
};
/**
* Converts milliseconds to human-readable duration (ms, s, m)
* @param ms Duration in milliseconds
* @returns Formatted string with appropriate unit
*/
export const formatDuration = (ms: number): string => {
if (!Number.isFinite(ms) || ms < 0) return "0ms";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${(ms / 60000).toFixed(1)}m`;
return `${(ms / 3600000).toFixed(1)}h`;
};

View File

@@ -18,7 +18,9 @@ export const getInstallUrl = () => {
export const isNetBirdHosted = () => {
const hostname = window.location.hostname;
if (hostname.includes("selfhosted")) return false;
return hostname.endsWith(".netbird.io") || hostname.endsWith(".wiretrustee.com");
return (
hostname.endsWith(".netbird.io") || hostname.endsWith(".wiretrustee.com")
);
};
export const isLocalDev = () => {