Compare commits

...

15 Commits

Author SHA1 Message Date
Maycon Santos
7400ac806e remove self-hosted proxies menu item (#640)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-05-14 17:50:07 +02:00
Viktor Liu
240ff5af9a Fix IPv6 input across reverse proxy, routes and resources (#638) 2026-05-14 16:43:02 +02:00
Eduard Gert
dc86c30463 Add self-hosted proxies (#636)
* Add self-hosted proxies

* fix selfhosted badge for domain
2026-05-12 15:22:12 +02:00
Nicolas Frati
e58f75ae3c Enable MFA for local users toggle (#615)
* implement enable mfa for local users toggle

* fix visibility check

* Added beta badge to MFA auth toggle
2026-05-08 16:51:17 +02:00
Viktor Liu
dc1adebd27 Add IPv6 overlay settings and peer display (#594) 2026-05-07 15:20:12 +02:00
Bethuel Mmbaga
d76cbd1122 Add Microsoft AD FS support for embedded Dex identity providers (#625) 2026-04-28 12:42:48 +03:00
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
96 changed files with 3582 additions and 535 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 ✅"

98
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.7",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
@@ -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",
@@ -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": {
@@ -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.7",
"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

@@ -2,7 +2,6 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
@@ -72,6 +71,7 @@ import ReverseProxiesProvider, {
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
@@ -469,31 +469,55 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
const { update } = usePeer();
const { mutate } = useSWRConfig();
const [showEditIPModal, setShowEditIPModal] = useState(false);
const [showEditIPv6Modal, setShowEditIPv6Modal] = useState(false);
const { permission } = usePermissions();
const countryText = useMemo(() => {
return getRegionByPeer(peer);
}, [getRegionByPeer, peer]);
const handleSaveIP = (newIP: string) => {
notify({
title: peer.name,
description: "NetBird Peer IP was successfully updated",
promise: update({ ip: newIP }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPModal(false);
}),
loadingMessage: "Updating peer IP...",
});
};
const handleSaveIPv6 = (newIPv6: string) => {
notify({
title: peer.name,
description: "NetBird Peer IPv6 was successfully updated",
promise: update({ ipv6: newIPv6 }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPv6Modal(false);
}),
loadingMessage: "Updating peer IPv6...",
});
};
return (
<>
<Modal open={showEditIPModal} onOpenChange={setShowEditIPModal}>
<EditIPModal
onSuccess={(newIP) => {
notify({
title: peer.name,
description: "Peer IP was successfully updated",
promise: update({ ip: newIP }).then(() => {
mutate("/peers/" + peer.id);
setShowEditIPModal(false);
}),
loadingMessage: "Updating peer IP...",
});
}}
peer={peer}
key={showEditIPModal ? 1 : 0}
/>
</Modal>
<PeerEditIPModal
version="v4"
currentIP={peer.ip}
open={showEditIPModal}
onOpenChange={setShowEditIPModal}
onSave={handleSaveIP}
key={showEditIPModal ? "v4-open" : "v4-closed"}
/>
<PeerEditIPModal
version="v6"
currentIP={peer.ipv6 || ""}
open={showEditIPv6Modal}
onOpenChange={setShowEditIPv6Modal}
onSave={handleSaveIPv6}
key={showEditIPv6Modal ? "v6-open" : "v6-closed"}
/>
<Card className={"w-full xl:w-1/2"}>
<Card.List>
<Card.ListItem
@@ -502,35 +526,48 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
copyText={"NetBird IP Address"}
label={
<>
<MapPin size={16} />
<MapPin size={16} className={"shrink-0"} />
NetBird IP Address
</>
}
valueToCopy={peer.ip}
value={
<div className="flex items-center gap-2 justify-between w-full">
<span>{peer.ip}</span>
{permission.peers.update && (
<button
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setShowEditIPModal(true);
}}
>
<PencilIcon size={14} />
</button>
)}
</div>
<EditableValue
value={peer.ip}
canEdit={permission.peers.update}
onEdit={() => setShowEditIPModal(true)}
/>
}
/>
{peer.ipv6 && (
<Card.ListItem
copy
tooltip={false}
copyText={"NetBird IPv6 Address"}
label={
<>
<MapPin size={16} className={"shrink-0"} />
NetBird IPv6 Address
</>
}
valueToCopy={peer.ipv6}
value={
<EditableValue
value={peer.ipv6}
canEdit={permission.peers.update}
onEdit={() => setShowEditIPv6Modal(true)}
/>
}
/>
)}
<Card.ListItem
copy
copyText={"Public IP Address"}
label={
<>
<NetworkIcon size={16} />
<NetworkIcon size={16} className={"shrink-0"} />
Public IP Address
</>
}
@@ -542,7 +579,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
copyText={"DNS label"}
label={
<>
<Globe size={16} />
<Globe size={16} className={"shrink-0"} />
Domain Name
</>
}
@@ -560,7 +597,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
copyText={"Hostname"}
label={
<>
<MonitorSmartphoneIcon size={16} />
<MonitorSmartphoneIcon size={16} className={"shrink-0"} />
Hostname
</>
}
@@ -570,7 +607,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<FlagIcon size={16} />
<FlagIcon size={16} className={"shrink-0"} />
Region
</>
}
@@ -600,7 +637,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<Cpu size={16} />
<Cpu size={16} className={"shrink-0"} />
Operating System
</>
}
@@ -611,7 +648,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<Barcode size={16} />
<Barcode size={16} className={"shrink-0"} />
Serial Number
</>
}
@@ -623,7 +660,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<CalendarDays size={16} />
<CalendarDays size={16} className={"shrink-0"} />
Registered on
</>
}
@@ -639,7 +676,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<History size={16} />
<History size={16} className={"shrink-0"} />
Last seen
</>
}
@@ -656,7 +693,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<NetBirdIcon size={16} />
<NetBirdIcon size={16} className={"shrink-0"} />
Agent Version
</>
}
@@ -667,7 +704,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
<Card.ListItem
label={
<>
<NetBirdIcon size={16} />
<NetBirdIcon size={16} className={"shrink-0"} />
UI Version
</>
}
@@ -765,82 +802,29 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly<ModalProps>) {
);
}
interface EditIPModalProps {
onSuccess: (ip: string) => void;
peer: Peer;
}
function EditIPModal({ onSuccess, peer }: Readonly<EditIPModalProps>) {
const [ip, setIP] = useState(peer.ip);
const [error, setError] = useState("");
const validateIP = (ipAddress: string) => {
const ipRegex =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipRegex.test(ipAddress);
};
const isDisabled = useMemo(() => {
if (ip === peer.ip) return true;
const trimmedIP = trim(ip);
return trimmedIP.length === 0 || !validateIP(ip);
}, [ip, peer.ip]);
React.useEffect(() => {
switch (true) {
case ip === peer.ip:
setError("");
break;
case !validateIP(ip):
setError("Please enter a valid IP, e.g., 100.64.0.15");
break;
default:
setError("");
break;
}
}, [ip, peer.ip]);
function EditableValue({
value,
canEdit,
onEdit,
}: {
value: string;
canEdit: boolean;
onEdit: () => void;
}) {
return (
<ModalContent maxWidthClass={"max-w-md"}>
<form>
<ModalHeader
title={"Edit Peer IP Address"}
description={"Update the NetBird IP address for this peer."}
color={"blue"}
/>
<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
placeholder={"e.g., 100.64.0.15"}
value={ip}
onChange={(e) => setIP(e.target.value)}
error={error}
/>
</div>
<Callout>Changes take effect when the peer reconnects.</Callout>
</div>
<ModalFooter className={"items-center"} separator={false}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => onSuccess(ip)}
disabled={isDisabled}
>
Save
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
<div className="flex items-center gap-2 justify-between w-full">
<span>{value}</span>
{canEdit && (
<button
className="flex w-7 h-7 items-center justify-center gap-2 text-nb-gray-400 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 rounded-md cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<PencilIcon size={14} />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Self-Hosted Proxies - Reverse Proxy - ${globalMetaTitle}`,
};
export default BlankLayout;

View File

@@ -0,0 +1,68 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const SelfHostedProxiesTable = lazy(
() =>
import(
"@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesTable"
),
);
export default function ReverseProxyClustersPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/self-hosted-proxies"}
label={"Self-Hosted Proxies"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Self-Hosted Proxies</h1>
<Paragraph>
Setup self-hosted proxies on your own infrastructure for full control
over traffic and geographic location.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink href={REVERSE_PROXY_CLUSTERS_DOCS_LINK} target={"_blank"}>
Self-Hosted Proxies
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Self-Hosted Proxies"}
hasAccess={permission?.services?.read}
>
<Suspense fallback={<SkeletonTable />}>
<SelfHostedProxiesTable headingTarget={portalTarget} />
</Suspense>
</RestrictedAccess>
</PageContainer>
);
}

View File

@@ -18,7 +18,7 @@ import {
} from "@utils/version";
export default function SSHPage() {
const { peerId, username, port } = useSSHQueryParams();
const { peerId, username, port, ipVersion } = useSSHQueryParams();
const {
data: peer,
@@ -48,6 +48,7 @@ export default function SSHPage() {
peer={peer}
username={username}
port={port}
ipVersion={ipVersion}
/>
) : (
<LoadingMessage message={"Starting ssh session..."} />
@@ -60,9 +61,10 @@ type Props = {
username: string;
port: string;
peer: Peer;
ipVersion: string | null;
};
function SSHTerminal({ username, port, peer }: Props) {
function SSHTerminal({ username, port, peer, ipVersion }: Props) {
const client = useNetBirdClient();
const connected = useRef(false);
const sshConnectedOnce = useRef(false);
@@ -81,9 +83,12 @@ function SSHTerminal({ username, port, peer }: Props) {
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
// Use the FQDN when an IP version is specified so the dialer resolves to the correct address family.
const sshHost = ipVersion ? peer.dns_label || peer.ip : peer.ip;
useEffect(() => {
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
}, [username, peer, client]);
document.title = `${username}@${sshHost} - ${peer.hostname}`;
}, [username, peer, client, sshHost]);
const handleReconnect = async () => {
if (!peer?.id) return;
@@ -97,9 +102,10 @@ function SSHTerminal({ username, port, peer }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
} catch (error) {
console.error("Reconnection failed:", error);
@@ -123,9 +129,10 @@ function SSHTerminal({ username, port, peer }: Props) {
const rules = [`${protocol}/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
});
if (res === SSHStatus.CONNECTED) {
sshConnectedOnce.current = true;

View File

@@ -23,6 +23,7 @@ export const idpIcon = (
zitadel: <ZitadelIcon size={size} />,
authentik: <AuthentikIcon size={size} />,
keycloak: <KeycloakIcon size={size} />,
adfs: <MicrosoftIcon size={size} />,
oidc: <KeyRound size={size} className="text-nb-gray-400" />,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

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

@@ -50,11 +50,11 @@ function CardListItem({
return (
<li
className={cn(
"flex justify-between px-4 border-b border-nb-gray-900 py-4 last:border-b-0 items-center h-full",
"flex justify-between px-4 border-b border-nb-gray-900 py-3.5 last:border-b-0 items-center h-full",
className,
)}
>
<div className={"flex gap-2.5 items-center text-sm"}>{label}</div>
<div className={"flex gap-2.5 items-center text-[0.84rem]"}>{label}</div>
<div className={"flex flex-col gap-2"}>
<CardTextItem
label={label}
@@ -100,7 +100,7 @@ const CardTextItem = ({
return (
<div
className={cn(
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
"text-right text-nb-gray-400 text-[0.84rem] flex items-center gap-2",
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>

View File

@@ -0,0 +1,129 @@
import useCopyToClipboard from "@hooks/useCopyToClipboard";
import { cn } from "@utils/helpers";
import { Copy } from "lucide-react";
import React from "react";
type CardTableProps = {
children: React.ReactNode;
className?: string;
};
function CardTable({ children, className }: CardTableProps) {
return (
<div
className={cn(
"bg-nb-gray-940 rounded-md border border-nb-gray-900 w-full overflow-hidden",
className,
)}
>
<table className={"w-full border-collapse text-sm"}>{children}</table>
</div>
);
}
function CardTableHeader({ children, className }: CardTableProps) {
return (
<thead>
<tr
className={cn(
"border-b border-nb-gray-900",
className,
)}
>
{children}
</tr>
</thead>
);
}
type CardTableHeaderCellProps = {
children: React.ReactNode;
width?: number;
className?: string;
};
function CardTableHeaderCell({
children,
width,
className,
}: CardTableHeaderCellProps) {
return (
<th
className={cn(
"px-4 py-2.5 text-left text-sm font-normal",
className,
)}
style={width ? { width } : undefined}
>
{children}
</th>
);
}
function CardTableBody({ children, className }: CardTableProps) {
return <tbody className={className}>{children}</tbody>;
}
type CardTableRowProps = {
children: React.ReactNode;
className?: string;
};
function CardTableRow({ children, className }: CardTableRowProps) {
return (
<tr
className={cn(
"border-b border-nb-gray-900 last:border-b-0",
className,
)}
>
{children}
</tr>
);
}
type CardTableCellProps = {
children: React.ReactNode;
copy?: boolean;
copyText?: string;
width?: number;
className?: string;
};
function CardTableCell({
children,
copy = false,
copyText,
width,
className,
}: CardTableCellProps) {
const [, copyToClipBoard] = useCopyToClipboard(copyText ?? "");
return (
<td
className={cn("px-4 py-3", className)}
style={width ? { width } : undefined}
>
<div
className={cn(
"text-nb-gray-400 text-sm flex items-center gap-2",
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
)}
onClick={() =>
copy &&
copyToClipBoard(`${copyText} has been copied to clipboard.`)
}
>
{children}
{copy && <Copy size={13} className={"shrink-0"} />}
</div>
</td>
);
}
CardTable.Header = CardTableHeader;
CardTable.HeaderCell = CardTableHeaderCell;
CardTable.Body = CardTableBody;
CardTable.Row = CardTableRow;
CardTable.Cell = CardTableCell;
export default CardTable;

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

@@ -290,7 +290,7 @@ export function PeerGroupSelector({
const searchPlaceholder = useMemo(() => {
if (tab === "groups") return placeholderForSearch;
if (tab === "resources") return "Search resource...";
if (tab === "peers") return "Search peer...";
if (tab === "peers") return "Search peer by name or ip...";
return "Search...";
}, [tab, placeholderForSearch]);
@@ -537,9 +537,6 @@ export function PeerGroupSelector({
const isSelected =
values.find((group) => group.name == option.name) !=
undefined;
const peerCount =
option.peers?.length ?? option?.peers_count ?? 0;
const isDisabled = disabledGroups
? disabledGroups?.findIndex(
(g) => g.id === option.id,
@@ -968,7 +965,8 @@ const ResourcesList = ({
const peersSearchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().includes(lowerCaseQuery);
if (item.ip.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ipv6?.toLowerCase().includes(lowerCaseQuery) ?? false;
};
const PeersList = ({

View File

@@ -30,7 +30,8 @@ const searchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().startsWith(lowerCaseQuery);
if (item.ip.toLowerCase().startsWith(lowerCaseQuery)) return true;
return !!item.ipv6?.toLowerCase().startsWith(lowerCaseQuery);
};
export function PeerSelector({
@@ -124,7 +125,6 @@ export function PeerSelector({
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
}
>
<MapPinIcon />
{value.ip}
</div>
</div>
@@ -238,7 +238,6 @@ export function PeerSelector({
!isSupported && "opacity-50",
)}
>
<MapPinIcon />
{option.ip}
</div>
</FullTooltip>

View File

@@ -98,7 +98,7 @@ const SelectItem = React.forwardRef<
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="flex-shrink-0">{icon}</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>

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

@@ -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

@@ -30,6 +30,7 @@ const PeerContext = React.createContext(
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
ipv6?: string;
}) => Promise<Peer>;
toggleSSH: (newState: boolean) => Promise<void>;
setSSHInstructionsModal: (open: boolean) => void;
@@ -80,6 +81,7 @@ export default function PeerProvider({
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
ipv6?: string;
}) => {
return peerRequest.put(
{
@@ -99,6 +101,7 @@ export default function PeerProvider({
? undefined
: props.approval_required,
ip: props.ip != undefined ? props.ip : undefined,
ipv6: props.ipv6 != undefined ? props.ipv6 : undefined,
},
`/${peer.id}`,
);

View File

@@ -2,6 +2,7 @@
import { notify } from "@components/Notification";
import useFetchApi, { useApiCall } from "@utils/api";
import { wrapIPv6 } from "@utils/ip";
import React, {
createContext,
useCallback,
@@ -15,6 +16,7 @@ import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
ReverseProxy,
ReverseProxyCluster,
ReverseProxyDomain,
ReverseProxyFlatTarget,
ReverseProxyTarget,
@@ -23,6 +25,7 @@ import {
} from "@/interfaces/ReverseProxy";
import ReverseProxyModal from "@/modules/reverse-proxy/ReverseProxyModal";
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
import { usePermissions } from "@/contexts/PermissionsProvider";
type ReverseProxiesContextValue = {
reverseProxies: ReverseProxy[] | undefined;
@@ -51,6 +54,9 @@ type ReverseProxiesContextValue = {
domain: string,
targetCluster: string,
) => Promise<ReverseProxyDomain>;
clusters: ReverseProxyCluster[] | undefined;
isClustersLoading: boolean;
isSelfHostedCluster: (clusterAddress?: string) => boolean;
};
type OpenModalOptions = {
@@ -90,10 +96,14 @@ export default function ReverseProxiesProvider({
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const { confirm } = useDialog();
const { permission } = usePermissions();
// Reverse Proxies
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
"/reverse-proxies/services",
false,
true,
permission?.services.read,
);
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
@@ -101,6 +111,9 @@ export default function ReverseProxiesProvider({
const { data: peers } = useFetchApi<Peer[]>("/peers");
const { data: resources } = useFetchApi<NetworkResource[]>(
"/networks/resources",
false,
true,
permission?.services.read,
);
const resolveDestination = useCallback(
@@ -125,12 +138,25 @@ export default function ReverseProxiesProvider({
// Domains
const { data: domains, isLoading: isLoadingDomains } = useFetchApi<
ReverseProxyDomain[]
>("/reverse-proxies/domains");
>("/reverse-proxies/domains", false, true, permission.services?.read);
const domainRequest = useApiCall<ReverseProxyDomain>(
"/reverse-proxies/domains",
true,
);
// Clusters
const { data: clusters, isLoading: isClustersLoading } = useFetchApi<
ReverseProxyCluster[]
>("/reverse-proxies/clusters", false, true, permission.services?.read);
const isSelfHostedCluster = useCallback(
(clusterAddress?: string) => {
if (!clusterAddress) return false;
return !!clusters?.find((c) => c.address === clusterAddress)?.self_hosted;
},
[clusters],
);
const [modalOpen, setModalOpen] = useState(false);
const [currentProxy, setCurrentProxy] = useState<ReverseProxy | undefined>();
const [initialTab, setInitialTab] = useState<string | undefined>();
@@ -483,6 +509,9 @@ export default function ReverseProxiesProvider({
createDomain,
validateDomain,
deleteDomain,
clusters,
isClustersLoading,
isSelfHostedCluster,
}}
>
{children}
@@ -604,7 +633,7 @@ function formatTargetDestination(
target: ReverseProxyTarget,
resolvedHost?: string,
): string {
const host = target.host || resolvedHost || "localhost";
const host = wrapIPv6(target.host || resolvedHost || "localhost");
const isDefault =
(target.protocol === "http" && target.port === 80) ||
(target.protocol === "https" && target.port === 443) ||

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

@@ -28,6 +28,9 @@ export interface Account {
auto_update_version: string;
auto_update_always: boolean;
local_auth_disabled?: boolean;
local_mfa_enabled?: boolean;
ipv6_enabled_groups?: string[];
network_range_v6?: string;
};
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 {
@@ -40,7 +44,8 @@ export type SSOIdentityProviderType =
| "pocketid"
| "microsoft"
| "authentik"
| "keycloak";
| "keycloak"
| "adfs";
export const SSOIdentityProviderOptions: {
value: SSOIdentityProviderType;
@@ -55,6 +60,7 @@ export const SSOIdentityProviderOptions: {
{ value: "pocketid", label: "PocketID" },
{ value: "authentik", label: "Authentik" },
{ value: "keycloak", label: "Keycloak" },
{ value: "adfs", label: "Microsoft AD FS" },
];
export const getSSOIdentityProviderLabelByType = (

View File

@@ -5,6 +5,7 @@ export interface Peer {
id?: string;
name: string;
ip: string;
ipv6?: string;
connected: boolean;
created_at?: Date;
last_seen: Date;

View File

@@ -15,12 +15,30 @@ export interface ReverseProxy {
proxy_cluster?: string;
targets: ReverseProxyTarget[];
enabled: boolean;
terminated?: 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;
@@ -77,6 +95,13 @@ export interface ReverseProxyAuth {
link_auth?: {
enabled: boolean;
};
header_auths?: HeaderAuthConfig[];
}
export interface HeaderAuthConfig {
enabled: boolean;
header: string;
value: string;
}
export interface ReverseProxyDomain {
@@ -86,6 +111,8 @@ export interface ReverseProxyDomain {
type: ReverseProxyDomainType;
target_cluster?: string;
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
}
export enum ReverseProxyDomainType {
@@ -129,9 +156,29 @@ 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 interface ReverseProxyCluster {
id?: string;
address: string;
connected_proxies: number;
self_hosted: boolean;
}
export interface ReverseProxyClusterToken {
id?: string;
name: string;
plain_token?: string;
expires_at?: string;
expires_in?: number;
created_at?: string;
last_used?: string;
revoked?: boolean;
}
export function isL4Event(event: ReverseProxyEvent): boolean {
@@ -181,5 +228,8 @@ 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

@@ -37,6 +37,7 @@ export default function Navigation({
return (
<div
data-navigation
className={cn(
"whitespace-nowrap md:border-r dark:border-zinc-700/40 bg-gray-50 dark:bg-nb-gray relative group/navigation transition-all",
hideOnMobile ? "hidden md:block" : "",

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

@@ -19,6 +19,7 @@ import { HelpTooltip } from "@components/HelpTooltip";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import { useApiCall } from "@utils/api";
import { normalizeHostCIDR } from "@utils/ip";
import { useDialog } from "@/contexts/DialogProvider";
import { usePolicies } from "@/contexts/PoliciesProvider";
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
@@ -172,7 +173,7 @@ export function ResourceModalContent({
const promise = create({
name,
description,
address,
address: normalizeHostCIDR(address),
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {
@@ -196,7 +197,7 @@ export function ResourceModalContent({
const promise = update({
name,
description,
address,
address: normalizeHostCIDR(address),
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
enabled,
}).then(async (r) => {

View File

@@ -58,7 +58,7 @@ export const ResourceSingleAddressInput = ({
// Case 2: If it's not a valid domain, check if it's a valid CIDR
if (!cidr.isValidAddress(value)) {
return "Please enter a valid IP or CIDR, e.g., 10.0.0.21, 192.168.1.0/24";
return "Please enter a valid IP or CIDR, e.g., 10.0.0.21, 192.168.1.0/24, 2001:db8::1 or 2001:db8::/64";
}
return ""; // Valid CIDR

View File

@@ -34,7 +34,7 @@ export const NetworkRoutingPeersTabContent = ({
return {
...router,
search: `${peer?.name ?? ""} ${peer?.ip ?? ""} ${user?.name ?? ""} ${user?.id ?? ""} ${group?.name ?? ""}`,
search: `${peer?.name ?? ""} ${peer?.ip ?? ""} ${peer?.ipv6 ?? ""} ${user?.name ?? ""} ${user?.id ?? ""} ${group?.name ?? ""}`,
};
});
}, [users, peers, routers, groups]);

View File

@@ -2,6 +2,7 @@ import Button from "@components/Button";
import { notify } from "@components/Notification";
import { RadioCard, RadioCardGroup } from "@components/RadioCard";
import { useApiCall } from "@utils/api";
import { normalizeHostCIDR } from "@utils/ip";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
@@ -67,7 +68,7 @@ export const OnboardingAddResource = ({
{
name: resourceType === "subnet" ? "My Subnet" : "My Resource",
description: "Created during onboarding",
address: resourceAddress,
address: normalizeHostCIDR(resourceAddress),
enabled: true,
groups: [],
},
@@ -178,15 +179,15 @@ export const OnboardingAddResource = ({
const description = useMemo(() => {
if (resourceType === "ip")
return "Enter a single IPv4 address of your resource";
return "Enter a single IPv4 or IPv6 address of your resource";
if (resourceType === "subnet") return "Enter a CIDR range of your network";
if (resourceType === "domain")
return "Enter a domain name of your resource";
}, [resourceType]);
const placeholder = useMemo(() => {
if (resourceType === "ip") return "e.g., 192.168.31.45";
if (resourceType === "subnet") return "e.g., 192.168.1.0/24";
if (resourceType === "ip") return "e.g., 192.168.31.45 or 2001:db8::1";
if (resourceType === "subnet") return "e.g., 192.168.1.0/24 or 2001:db8::/64";
if (resourceType === "domain")
return "e.g., service.internal or *.services.internal";
}, [resourceType]);
@@ -211,13 +212,13 @@ export const OnboardingAddResource = ({
value={"ip"}
title={"Single IP Address"}
icon={<WorkflowIcon size={12} />}
description={"IPv4 address like 192.168.31.45"}
description={"IPv4 or IPv6 address like 192.168.31.45"}
/>
<RadioCard
value={"subnet"}
title={"Entire Subnet"}
icon={<NetworkIcon size={12} />}
description={"CIDR range like 192.168.0.0/24"}
description={"CIDR range like 192.168.0.0/24 or 2001:db8::/64"}
/>
<RadioCard
value={"domain"}

View File

@@ -32,8 +32,9 @@ export const OnboardingTestResource = ({
const pingAddress = useMemo(() => {
let a = resource?.address || "";
if (isHost && a.endsWith("/32")) {
a = a.slice(0, -3);
if (isHost) {
if (a.endsWith("/32")) a = a.slice(0, -3);
else if (a.endsWith("/128")) a = a.slice(0, -4);
}
if (isWildCard) return `(any subdomain of ${a})`;
return isSubnet ? `(resource ip in your subnet)` : a;

View File

@@ -0,0 +1,118 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import { Input } from "@components/Input";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import cidr from "ip-cidr";
import { trim } from "lodash";
import React, { useMemo, useState } from "react";
type IPVersion = "v4" | "v6";
interface PeerEditIPModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (ip: string) => void;
currentIP: string;
version: IPVersion;
}
const config: Record<
IPVersion,
{
title: string;
description: string;
placeholder: string;
errorMessage: string;
validate: (ip: string) => boolean;
}
> = {
v4: {
title: "Edit Peer IP Address",
description: "Update the NetBird IP address for this peer.",
placeholder: "e.g., 100.64.0.15",
errorMessage: "Please enter a valid IP, e.g., 100.64.0.15",
validate: (ip: string) =>
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(
ip,
),
},
v6: {
title: "Edit Peer IPv6 Address",
description: "Update the NetBird IPv6 address for this peer.",
placeholder: "e.g., fd00:1234::1",
errorMessage: "Please enter a valid IPv6 address, e.g., fd00:1234::1",
validate: (ip: string) => cidr.isValidAddress(ip) && ip.includes(":"),
},
};
export function PeerEditIPModal({
open,
onOpenChange,
onSave,
currentIP,
version,
}: Readonly<PeerEditIPModalProps>) {
const { title, description, placeholder, errorMessage, validate } =
config[version];
const [ip, setIP] = useState(currentIP);
const isDisabled = useMemo(() => {
if (ip === currentIP) return true;
const trimmed = trim(ip);
return trimmed.length === 0 || !validate(trimmed);
}, [ip, currentIP, validate]);
const error = useMemo(() => {
if (ip === currentIP) return "";
if (!validate(trim(ip))) return errorMessage;
return "";
}, [ip, currentIP, validate, errorMessage]);
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass={"max-w-md"}>
<form>
<ModalHeader title={title} description={description} color={"blue"} />
<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
placeholder={placeholder}
value={ip}
onChange={(e) => setIP(e.target.value)}
error={error}
/>
</div>
<Callout>Changes take effect when the peer reconnects.</Callout>
</div>
<ModalFooter className={"items-center"} separator={false}>
<div className={"flex gap-3 w-full justify-end"}>
<ModalClose asChild={true}>
<Button variant={"secondary"} className={"w-full"}>
Cancel
</Button>
</ModalClose>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => onSave(trim(ip))}
disabled={isDisabled}
>
Save
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
</Modal>
);
}

View File

@@ -11,6 +11,7 @@ import { PeerAddressTooltipContent } from "@/modules/peers/PeerAddressTooltipCon
type Props = {
peer: Peer;
};
export default function PeerAddressCell({ peer }: Props) {
return (
<FullTooltip

View File

@@ -38,6 +38,21 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
</CopyToClipboardText>
}
/>
{peer.ipv6 && (
<ListItem
icon={<MapPin size={14} />}
label={"NetBird IPv6"}
value={
<CopyToClipboardText
iconAlignment={"right"}
message={"NetBird IPv6 has been copied to your clipboard"}
alwaysShowIcon={true}
>
{peer.ipv6}
</CopyToClipboardText>
}
/>
)}
<ListItem
icon={<NetworkIcon size={14} />}
label={"Public IP"}

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} />,
@@ -204,6 +214,10 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
</PeerProvider>
),
},
{
id: "ipv6",
accessorFn: (row) => row.ipv6,
},
];
type Props = {
@@ -226,17 +240,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,
},
],
);
@@ -321,6 +331,7 @@ export default function PeersTable({
connect: permission.peers.update,
groups: permission.groups.read,
os: false,
ipv6: false,
}}
isLoading={isLoading}
getStartedCard={<NoPeersGettingStarted showBackground={true} />}

View File

@@ -5,6 +5,7 @@ interface SSHConfig {
hostname: string;
port: number;
username: string;
ipVersion?: string;
}
interface SSHConnection {
@@ -71,6 +72,7 @@ export const useSSH = (client: any) => {
config.port,
config.username,
requiresJwt ? accessToken : undefined,
config.ipVersion,
);
ssh.onclose = () => {

View File

@@ -6,6 +6,7 @@ interface SSHQueryParams {
peerId: string | null;
username: string | null;
port: string | null;
ipVersion: string | null;
}
export function useSSHQueryParams() {
@@ -15,6 +16,7 @@ export function useSSHQueryParams() {
peerId: null,
username: null,
port: null,
ipVersion: null,
});
const [, setLocalQueryParams] = useLocalStorage("netbird-query-params", "");
@@ -22,10 +24,11 @@ export function useSSHQueryParams() {
const peerId = searchParams.get("id");
const username = searchParams.get("user");
const port = searchParams.get("port");
const ipVersion = searchParams.get("ip_version");
// If all params are present in URL, use them
if (peerId && username && port) {
setParams({ peerId, username, port });
setParams({ peerId, username, port, ipVersion });
return;
}
@@ -47,18 +50,23 @@ export function useSSHQueryParams() {
const storedPeerId = urlParams.get("id");
const storedUsername = urlParams.get("user");
const storedPort = urlParams.get("port");
const storedIpVersion = urlParams.get("ip_version");
if (storedPeerId && storedUsername && storedPort) {
const newSearchParams = new URLSearchParams();
newSearchParams.set("id", storedPeerId);
newSearchParams.set("user", storedUsername);
newSearchParams.set("port", storedPort);
if (storedIpVersion) {
newSearchParams.set("ip_version", storedIpVersion);
}
router.replace(`/peer/ssh?${newSearchParams.toString()}`);
setParams({
peerId: storedPeerId,
username: storedUsername,
port: storedPort,
ipVersion: storedIpVersion,
});
// Clear stored params after restoring

View File

@@ -224,6 +224,7 @@ export const useNetBirdClient = () => {
port: number,
username: string,
jwtToken?: string,
ipVersion?: string,
): Promise<any> => {
if (!netBirdClient.current?.createSSHConnection) {
throw new Error("Go client not ready");
@@ -233,6 +234,7 @@ export const useNetBirdClient = () => {
port,
username,
jwtToken,
ipVersion,
);
},
[],

View File

@@ -0,0 +1,364 @@
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 { isIPv6 } from "@utils/ip";
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") {
let val = rule.value;
if (!val.includes("/")) {
const suffix = isIPv6(val) ? 128 : 32;
val = `${val}/${suffix}`;
}
if (!cidr.isValidAddress(val)) {
return "Please enter a valid IP address, e.g., 85.203.15.42 or 2001:db8::1";
}
} else {
if (!rule.value.includes("/") || !cidr.isValidAddress(rule.value)) {
return "Please enter a valid CIDR block, e.g., 74.125.0.0/16 or 2001:db8::/64";
}
}
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 or 2001:db8::1"
: "e.g., 74.125.0.0/16 or 2001:db8::/64"
}
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

@@ -46,7 +46,7 @@ export default function ReverseProxyHTTPTargets({
}: Readonly<Props>) {
return (
<div>
<Label>HTTP/S Targets</Label>
<Label>HTTPS Targets</Label>
<HelpText>
Add one or more devices running your service or resources to make it
publicly accessible.
@@ -93,10 +93,7 @@ export default function ReverseProxyHTTPTargets({
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="default-outline"
className="!px-3"
>
<Button variant="default-outline" className="!px-3">
<MoreVertical size={16} className="shrink-0" />
</Button>
</DropdownMenuTrigger>
@@ -104,9 +101,7 @@ export default function ReverseProxyHTTPTargets({
className="w-auto min-w-[200px]"
align="end"
>
<DropdownMenuItem
onClick={() => onEditTarget(index)}
>
<DropdownMenuItem onClick={() => onEditTarget(index)}>
<div className="flex gap-3 items-center">
<Edit size={14} className="shrink-0" />
Edit Target
@@ -117,10 +112,7 @@ export default function ReverseProxyHTTPTargets({
onClick={() => onRemoveTarget(index)}
>
<div className="flex gap-3 items-center">
<MinusCircleIcon
size={14}
className="shrink-0"
/>
<MinusCircleIcon size={14} className="shrink-0" />
Remove Target
</div>
</DropdownMenuItem>
@@ -151,10 +143,7 @@ export default function ReverseProxyHTTPTargets({
variant="warning"
className="mt-3"
icon={
<AlertTriangle
size={14}
className="shrink-0 relative top-[3px]"
/>
<AlertTriangle size={14} className="shrink-0 relative top-[3px]" />
}
>
There are currently no resources in your network{" "}

View File

@@ -66,7 +66,7 @@ export default function ReverseProxyLayer4Content({
<Label>
Listen Port
<HelpTooltip
className={"max-w-sm"}
className={isListenPortSupported ? "max-w-sm" : "max-w-xs"}
content={
isListenPortSupported
? "Enter the public listen port this service will be reachable on."

View File

@@ -21,12 +21,14 @@ import {
Binary,
ClockFadingIcon,
ExternalLinkIcon,
FileCode2Icon,
GlobeIcon,
LockKeyhole,
MapPinned,
PlusCircle,
RectangleEllipsis,
Settings,
ShieldCheckIcon,
Users,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -37,7 +39,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
AccessRestrictions,
HeaderAuthConfig,
isL4Mode as isL4ServiceMode,
REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
REVERSE_PROXY_SERVICES_DOCS_LINK,
REVERSE_PROXY_SETTINGS_DOCS_LINK,
@@ -53,6 +58,7 @@ import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput";
import { useReverseProxyDomain } from "./domain/useReverseProxyDomain";
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
import AuthHeaderModal from "@/modules/reverse-proxy/auth/AuthHeaderModal";
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets";
@@ -61,14 +67,15 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
import { type Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
import { useReverseProxyAddress } from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
import {
validateTimeout,
validateSessionIdleTimeout,
validateTimeout,
} from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import {
ReverseProxyServiceModeSelector,
SERVICE_MODES,
} from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
import { ReverseProxyAccessControlRules } from "@/modules/reverse-proxy/ReverseProxyAccessControlRules";
type Props = {
open: boolean;
@@ -236,10 +243,24 @@ export default function ReverseProxyModal({
reverseProxy?.auth?.link_auth?.enabled ?? false,
);
const [headerAuthsEnabled, setHeaderAuthsEnabled] = useState(
(reverseProxy?.auth?.header_auths ?? []).some((h) => h.enabled),
);
const [headerAuths, setHeaderAuths] = useState<HeaderAuthConfig[]>(
reverseProxy?.auth?.header_auths ?? [],
);
const [accessRestrictions, setAccessRestrictions] = useState<
AccessRestrictions | undefined
>(reverseProxy?.access_restrictions);
const [accessControlHasErrors, setAccessControlHasErrors] = useState(false);
// Auth modal states
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [ssoModalOpen, setSsoModalOpen] = useState(false);
const [pinModalOpen, setPinModalOpen] = useState(false);
const [headerModalOpen, setHeaderModalOpen] = useState(false);
// Target being added/edited
const [targetModalOpen, setTargetModalOpen] = useState(false);
@@ -248,8 +269,12 @@ export default function ReverseProxyModal({
);
const canContinueToSettings = useMemo(() => {
const subdomainRequired =
selectedDomain?.require_subdomain === true;
const isSubdomainValid =
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists;
baseDomain.length > 0 &&
!domainAlreadyExists &&
(subdomain.length > 0 || !subdomainRequired);
const isValidPort = (port: number) => port >= 1 && port <= 65535;
const hasHttpEndpoint = !isL4Mode && targets.length > 0;
const hasL4Endpoint =
@@ -264,6 +289,7 @@ export default function ReverseProxyModal({
subdomain,
baseDomain,
domainAlreadyExists,
selectedDomain,
serviceMode,
targets.length,
isL4Mode,
@@ -305,16 +331,20 @@ export default function ReverseProxyModal({
);
};
const hasNoAuth =
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
const isUnprotected =
!passwordEnabled &&
!pinEnabled &&
!bearerEnabled &&
!linkAuthEnabled &&
!headerAuthsEnabled &&
!accessRestrictions;
const handleSubmit = async () => {
// Show warning if no authentication is configured (HTTP only; TLS is pass-through)
if (!isL4Mode && hasNoAuth) {
if (isUnprotected) {
const confirmed = await confirm({
title: "No Authentication Configured",
title: "No Protection Configured",
description:
"This service will be publicly accessible to everyone on the internet without any restrictions. Are you sure you want to continue?",
"This service has no authentication or access control rules configured. It will be publicly accessible to everyone on the internet. Are you sure you want to continue?",
type: "warning",
confirmText: reverseProxy ? "Save Changes" : "Add Service",
cancelText: "Cancel",
@@ -341,6 +371,9 @@ export default function ReverseProxyModal({
link_auth: {
enabled: linkAuthEnabled,
},
header_auths: headerAuthsEnabled
? headerAuths.map((h) => ({ ...h, enabled: true }))
: [],
};
const l4TargetPayload: ReverseProxyTarget | undefined = l4Target
@@ -383,6 +416,7 @@ export default function ReverseProxyModal({
pass_host_header: isL4Mode ? undefined : passHostHeader,
rewrite_redirects: isL4Mode ? undefined : rewriteRedirects,
auth: isL4Mode ? undefined : auth,
access_restrictions: accessRestrictions,
},
proxyId: reverseProxy?.id,
onSuccess: () => {
@@ -426,10 +460,17 @@ export default function ReverseProxyModal({
</TabsTrigger>
{!isL4Mode && (
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
<LockKeyhole size={16} />
<LockKeyhole size={14} />
Authentication
</TabsTrigger>
)}
<TabsTrigger
value={"access-control"}
disabled={!canContinueToSettings}
>
<ShieldCheckIcon size={14} />
Access Control
</TabsTrigger>
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
<Settings size={14} />
Advanced Settings
@@ -444,6 +485,7 @@ export default function ReverseProxyModal({
baseDomain={baseDomain}
onBaseDomainChange={setBaseDomain}
domainAlreadyExists={domainAlreadyExists}
subdomainRequired={selectedDomain?.require_subdomain === true}
clusterOffline={
reverseProxy?.proxy_cluster && !isClusterConnected
? { clusterName: reverseProxy.proxy_cluster }
@@ -527,10 +569,32 @@ export default function ReverseProxyModal({
enabled={pinEnabled}
onClick={() => setPinModalOpen(true)}
/>
<SettingCard.Item
label={
<>
<FileCode2Icon size={15} />
HTTP Headers
</>
}
description="Require specific HTTP headers to access this service."
enabled={headerAuthsEnabled}
onClick={() => setHeaderModalOpen(true)}
/>
</SettingCard>
</div>
</TabsContent>
<TabsContent value={"access-control"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-4"}>
<ReverseProxyAccessControlRules
value={accessRestrictions}
onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec}
/>
</div>
</TabsContent>
<TabsContent value={"settings"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
{(serviceMode === ServiceMode.TCP ||
@@ -627,6 +691,10 @@ export default function ReverseProxyModal({
href: REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
label: "Authentication",
},
"access-control": {
href: REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
label: "Access Control",
},
settings: {
href: REVERSE_PROXY_SETTINGS_DOCS_LINK,
label: "Settings",
@@ -653,7 +721,9 @@ export default function ReverseProxyModal({
</ModalClose>
<Button
variant={"primary"}
onClick={() => setTab(isL4Mode ? "settings" : "auth")}
onClick={() =>
setTab(isL4Mode ? "access-control" : "auth")
}
disabled={!canContinueToSettings}
>
Continue
@@ -669,9 +739,27 @@ export default function ReverseProxyModal({
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => setTab("access-control")}
>
Continue
</Button>
</>
)}
{tab === "access-control" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => setTab("settings")}
disabled={accessControlHasErrors}
>
Continue
</Button>
@@ -682,7 +770,7 @@ export default function ReverseProxyModal({
<>
<Button
variant={"secondary"}
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
onClick={() => setTab("access-control")}
>
Back
</Button>
@@ -691,7 +779,8 @@ export default function ReverseProxyModal({
disabled={
!canContinueToSettings ||
!permission?.services?.create ||
!!timeoutError
!!timeoutError ||
accessControlHasErrors
}
onClick={handleSubmit}
>
@@ -711,7 +800,8 @@ export default function ReverseProxyModal({
disabled={
!canContinueToSettings ||
!permission?.services?.update ||
!!timeoutError
!!timeoutError ||
accessControlHasErrors
}
onClick={handleSubmit}
>
@@ -806,6 +896,25 @@ export default function ReverseProxyModal({
}, 200);
}}
/>
<AuthHeaderModal
open={headerModalOpen}
onOpenChange={setHeaderModalOpen}
key={headerModalOpen ? "h1" : "h0"}
currentHeaders={headerAuths}
onSave={(headers) => {
setTimeout(() => {
setHeaderAuths(headers);
setHeaderAuthsEnabled(true);
}, 200);
}}
onRemove={() => {
setTimeout(() => {
setHeaderAuths([]);
setHeaderAuthsEnabled(false);
}, 200);
}}
/>
</Modal>
);
}

View File

@@ -31,7 +31,7 @@ type ServiceModeConfig = {
export const SERVICE_MODES: Record<ServiceMode, ServiceModeConfig> = {
[ServiceMode.HTTP]: {
label: "HTTP/S Service",
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} />,
@@ -64,7 +64,7 @@ export const ReverseProxyServiceModeSelector = ({
}: Props) => {
const selected = value ?? ServiceMode.HTTP;
const selectedMode = SERVICE_MODES[selected];
const isL4Supported = domain?.supports_custom_ports === true;
const isL4Supported = domain?.supports_custom_ports !== undefined;
// Reset to HTTP if the current L4 mode becomes unsupported (e.g. domain changed)
useEffect(() => {

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

@@ -10,6 +10,7 @@ import { useMemo } from "react";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyDomainType } from "@/interfaces/ReverseProxy";
import { isNetBirdHosted } from "@utils/netbird";
import TruncatedText from "@components/ui/TruncatedText";
interface DomainSelectorProps {
value: string;
@@ -25,7 +26,7 @@ export function CustomDomainSelector({
className,
}: DomainSelectorProps) {
const router = useRouter();
const { domains } = useReverseProxies();
const { domains, isSelfHostedCluster } = useReverseProxies();
const options: SelectOption[] = useMemo(() => {
const opts: SelectOption[] = [];
@@ -34,15 +35,20 @@ export function CustomDomainSelector({
domains
?.filter((d) => d.type === ReverseProxyDomainType.FREE)
.forEach((domain) => {
const isSelfHosted = isSelfHostedCluster(
domain?.target_cluster ?? domain?.domain,
);
opts.push({
value: domain.domain,
label: `.${domain.domain}`,
renderItem: () => (
<div className="flex items-center gap-2 w-full text-sm justify-between">
<div className="flex items-center gap-2">
<span>.{domain.domain}</span>
<TruncatedText text={`.${domain.domain}`} maxWidth={"260px"} />
</div>
{isNetBirdHosted() ? (
{isSelfHosted ? (
<SmallBadge text="Self-hosted" variant="sky" size="md" />
) : isNetBirdHosted() ? (
<SmallBadge text="Free" variant="green" size="md" />
) : (
<SmallBadge text="Cluster" variant="green" size="md" />
@@ -83,7 +89,7 @@ export function CustomDomainSelector({
});
return opts;
}, [domains]);
}, [domains, isSelfHostedCluster]);
const handleChange = (selectedValue: string) => {
if (selectedValue === "add_custom") {
@@ -98,7 +104,7 @@ export function CustomDomainSelector({
value={value}
onChange={handleChange}
options={options}
popoverWidth={335}
popoverWidth={380}
showSearch={true}
searchPlaceholder="Search domains..."
disabled={disabled}

View File

@@ -146,7 +146,7 @@ export default function CustomDomainsTable({ headingTarget }: Readonly<Props>) {
<GetStartedTest
icon={
<SquareIcon
icon={<GlobeIcon className={"fill-nb-gray-200"} size={20} />}
icon={<GlobeIcon className={"text-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>

View File

@@ -13,6 +13,7 @@ type Props = {
baseDomain: string;
onBaseDomainChange: (value: string) => void;
domainAlreadyExists: boolean;
subdomainRequired?: boolean;
clusterOffline?: {
clusterName: string;
};
@@ -24,13 +25,16 @@ export default function ReverseProxyDomainInput({
baseDomain,
onBaseDomainChange,
domainAlreadyExists,
subdomainRequired = false,
clusterOffline,
}: Readonly<Props>) {
return (
<div>
<Label>Domain</Label>
<HelpText>
Enter a subdomain and select a domain for your service.
{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">
@@ -47,7 +51,7 @@ export default function ReverseProxyDomainInput({
? "This domain is already used by another service."
: undefined
}
placeholder={"myapp"}
placeholder={subdomainRequired ? "myapp" : "myapp (optional)"}
className="!rounded-r-none !border-r-0"
/>
</div>

View File

@@ -23,6 +23,13 @@ function parseDomain(
.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)),
@@ -103,7 +110,11 @@ export function useReverseProxyDomain({
return customDomain?.domain || freeDomain?.domain || "";
});
const fullDomain = baseDomain ? `${subdomain}.${baseDomain}` : subdomain;
const fullDomain = baseDomain
? subdomain
? `${subdomain}.${baseDomain}`
: baseDomain
: subdomain;
const domainAlreadyExists = useMemo(() => {
if (!reverseProxies || !fullDomain) return false;

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

@@ -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,5 +1,6 @@
import CopyToClipboardText from "@components/CopyToClipboardText";
import TruncatedText from "@components/ui/TruncatedText";
import { wrapIPv6 } from "@utils/ip";
import * as React from "react";
import {
isL4Event,
@@ -32,9 +33,10 @@ export const ReverseProxyEventsUrlCell = ({ event, service }: Props) => {
const isL4 = isL4Event(event);
const listenPort = service?.listen_port;
const wrappedHost = wrapIPv6(event.host || "");
const hostWithPort =
isL4 && listenPort ? `${event.host}:${listenPort}` : event.host || "-";
const fullUrl = isL4 ? hostWithPort : `${event.host}${event.path}`;
isL4 && listenPort ? `${wrappedHost}:${listenPort}` : wrappedHost || "-";
const fullUrl = isL4 ? hostWithPort : `${wrappedHost}${event.path}`;
return (
<TruncatedText
@@ -51,7 +53,7 @@ export const ReverseProxyEventsUrlCell = ({ event, service }: 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-200">{wrappedHost}</span>
{isL4 && listenPort && (
<span className="text-nb-gray-300">:{listenPort}</span>
)}

View File

@@ -0,0 +1,60 @@
import Button from "@components/Button";
import { notify } from "@components/Notification";
import { useApiCall } from "@utils/api";
import { Trash2 } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
type Props = {
cluster: ReverseProxyCluster;
};
export default function SelfHostedProxiesActionCell({
cluster,
}: Readonly<Props>) {
const { confirm } = useDialog();
const request = useApiCall<ReverseProxyCluster>("/reverse-proxies/clusters");
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const handleDelete = async () => {
const choice = await confirm({
title: `Delete '${cluster.address}'?`,
description:
"Are you sure you want to delete this proxy cluster? This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel",
type: "danger",
maxWidthClass: "max-w-md",
});
if (!choice) return;
notify({
title: cluster.address,
description: "Proxy cluster was successfully deleted",
promise: request
.del({}, `/${encodeURIComponent(cluster.address)}`)
.then(() => {
mutate("/reverse-proxies/clusters");
}),
loadingMessage: "Deleting the proxy cluster...",
});
};
return (
<div className={"flex justify-end pr-4"}>
<Button
variant={"danger-outline"}
size={"sm"}
onClick={handleDelete}
disabled={!permission?.services?.delete}
>
<Trash2 size={16} />
Delete
</Button>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import Badge from "@components/Badge";
import { Server } from "lucide-react";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
type Props = {
cluster: ReverseProxyCluster;
};
export default function SelfHostedProxiesConnectedCell({
cluster,
}: Readonly<Props>) {
const count = cluster.connected_proxies;
return (
<div className="flex items-center w-full">
<Badge variant={"gray"}>
<Server size={11} className={"relative -top-[0.5px]"} />
<div>
<span className="font-medium text-xs">
{count > 0 ? count : "No Proxies Connected"}
</span>
</div>
</Badge>
</div>
);
}

View File

@@ -0,0 +1,339 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import { notify } from "@components/Notification";
import CardTable from "@components/CardTable";
import Code from "@components/Code";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import InlineLink from "@components/InlineLink";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import Paragraph from "@components/Paragraph";
import ModalHeader from "@components/modal/ModalHeader";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import {
ExternalLinkIcon,
GlobeIcon,
ListIcon,
Loader2,
ServerIcon,
SquareTerminalIcon,
} from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useApiCall } from "@/utils/api";
import { cn, validator } from "@utils/helpers";
import { GRPC_API_ORIGIN, isNetBirdHosted } from "@/utils/netbird";
import {
REVERSE_PROXY_CLUSTERS_DOCS_LINK,
ReverseProxyClusterToken,
} from "@/interfaces/ReverseProxy";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export const SelfHostedProxiesModal = ({ open, onOpenChange }: Props) => {
const { mutate } = useSWRConfig();
const [tab, setTab] = useState("domain");
const [domain, setDomain] = useState("");
const [token, setToken] = useState("");
const [isGeneratingToken, setIsGeneratingToken] = useState(true);
const tokenRequest = useApiCall<ReverseProxyClusterToken>(
"/reverse-proxies/proxy-tokens",
);
const domainError = useMemo(() => {
if (!domain) return "";
const isValid = validator.isValidDomain(domain, {
allowWildcard: false,
allowOnlyTld: false,
preventLeadingAndTrailingDots: true,
});
if (!isValid) {
return "Please enter a valid TLD domain, e.g., company.com";
}
return "";
}, [domain]);
const managementUrl = isNetBirdHosted()
? "https://api.netbird.io"
: GRPC_API_ORIGIN || "";
const dockerCommand = `docker run -d \\
-v /var/lib/certs:/certs \\
-e NB_PROXY_CERTIFICATE_DIRECTORY=/certs \\
-e NB_PROXY_ALLOW_INSECURE=true \\
-e NB_PROXY_MANAGEMENT_ADDRESS=${managementUrl} \\
-e NB_PROXY_ACME_CERTIFICATES=true \\
-e NB_PROXY_DOMAIN=${domain} \\
-e NB_PROXY_LOG_LEVEL=info \\
-e NB_PROXY_TOKEN=${token || "<TOKEN>"} \\
-p 80:80 -p 443:443 \\
netbirdio/reverse-proxy:latest`;
const generateToken = useCallback(async () => {
setIsGeneratingToken(true);
const promise = tokenRequest
.post({
name: domain,
expires_in: 0,
})
.then((res) => {
setToken(res?.plain_token ?? "");
})
.finally(() => {
setIsGeneratingToken(false);
});
notify({
title: "Proxy Token",
description: "Failed to generate proxy token",
promise,
loadingMessage: "Generating proxy token...",
showOnlyError: true,
preventSuccessToast: true,
});
return promise;
}, [domain, tokenRequest]);
const goToInstall = useCallback(() => {
setTab("install");
if (!token) generateToken();
}, [token, generateToken]);
const finishSetup = () => {
onOpenChange(false);
mutate("/reverse-proxies/clusters");
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass={"relative max-w-[600px]"} showClose={true}>
<ModalHeader
icon={<ServerIcon size={16} />}
title={"Setup Proxy"}
description={"Setup a self-hosted reverse proxy"}
color={"netbird"}
/>
<Tabs
value={tab}
onValueChange={(v) => (v === "install" ? goToInstall() : setTab(v))}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"domain"}>
<GlobeIcon size={14} />
Domain
</TabsTrigger>
<TabsTrigger
value={"dns"}
disabled={!domain.trim() || !!domainError}
>
<ListIcon size={14} />
DNS Records
</TabsTrigger>
<TabsTrigger
value={"install"}
disabled={!domain.trim() || !!domainError}
>
<SquareTerminalIcon size={14} />
Run the Proxy
</TabsTrigger>
</TabsList>
<TabsContent value={"domain"} className={"pb-8"}>
<div className={"px-8 flex flex-col gap-6"}>
<div>
<Label>Domain</Label>
<HelpText>
Enter a domain name that will be used for your proxy.
</HelpText>
<Input
autoFocus={true}
placeholder={"e.g., proxy.company.com"}
value={domain}
error={domainError}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setDomain(e.target.value)
}
/>
</div>
<Callout variant={"info"}>
In order to run the proxy, please make sure your machine meets
the following requirements:
<ul className={"list-disc pl-4 mt-2 flex flex-col gap-1"}>
<li>
<span className={"text-white font-medium"}>
Publicly accessible IP address
</span>
</li>
<li>
<span className={"text-white font-medium"}>Docker</span>{" "}
installed and running
</li>
<li>
<span className={"text-white font-medium"}>
Port 80 and 443
</span>{" "}
open and not in use
</li>
</ul>
</Callout>
</div>
</TabsContent>
<TabsContent value={"dns"} className={"pb-8"}>
<div className={"px-8 flex flex-col"}>
<div>
<Label>Configure DNS</Label>
<HelpText>
Add the following DNS records pointing to your machine&apos;s
public IP address.
</HelpText>
</div>
<CardTable>
<CardTable.Header>
<CardTable.HeaderCell width={120}>Type</CardTable.HeaderCell>
<CardTable.HeaderCell>Name</CardTable.HeaderCell>
<CardTable.HeaderCell>Content</CardTable.HeaderCell>
</CardTable.Header>
<CardTable.Body>
<CardTable.Row>
<CardTable.Cell>A Record</CardTable.Cell>
<CardTable.Cell copy copyText={domain}>
{domain}
</CardTable.Cell>
<CardTable.Cell className={"italic"}>
Your machine&apos;s IP
</CardTable.Cell>
</CardTable.Row>
<CardTable.Row>
<CardTable.Cell>A Record</CardTable.Cell>
<CardTable.Cell copy copyText={`*.${domain}`}>
{`*.${domain}`}
</CardTable.Cell>
<CardTable.Cell className={"italic"}>
Your machine&apos;s IP
</CardTable.Cell>
</CardTable.Row>
</CardTable.Body>
</CardTable>
</div>
</TabsContent>
<TabsContent value={"install"} className={"pb-8"}>
<div className={"px-8 flex flex-col"}>
<div>
<Label>Run the Proxy with Docker</Label>
<HelpText>
Run the following command on your machine to start the proxy.
</HelpText>
</div>
<Code
codeToCopy={dockerCommand}
className={cn(
"overflow-hidden",
isGeneratingToken && "!border-nb-gray-930",
)}
showCopyIcon={!isGeneratingToken}
>
{isGeneratingToken && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 text-nb-gray-100 bg-nb-gray-950/90">
<Loader2 size={16} className="animate-spin" />
Generating proxy token...
</div>
)}
<Code.Line>docker run -d \</Code.Line>
<Code.Line> -v /var/lib/certs:/certs \</Code.Line>
<Code.Line>
{" "}
-e NB_PROXY_CERTIFICATE_DIRECTORY=/certs \
</Code.Line>
<Code.Line> -e NB_PROXY_ALLOW_INSECURE=true \</Code.Line>
<Code.Line>
{" "}
-e NB_PROXY_MANAGEMENT_ADDRESS=
<span className={"text-netbird"}>{managementUrl}</span> \
</Code.Line>
<Code.Line> -e NB_PROXY_ACME_CERTIFICATES=true \</Code.Line>
<Code.Line>
{" "}
-e NB_PROXY_DOMAIN=
<span className={"text-netbird"}>{domain}</span> \
</Code.Line>
<Code.Line> -e NB_PROXY_LOG_LEVEL=info \</Code.Line>
<Code.Line>
{" "}
-e NB_PROXY_TOKEN=
<span className={"text-netbird"}>{token || "<TOKEN>"}</span> \
</Code.Line>
<Code.Line> -p 80:80 -p 443:443 \</Code.Line>
<Code.Line> netbirdio/reverse-proxy:latest</Code.Line>
</Code>
</div>
</TabsContent>
</Tabs>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
target={"_blank"}
>
Self-Hosted Proxies
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
{tab === "domain" && (
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={() => setTab("dns")}
disabled={!domain.trim() || !!domainError}
>
Continue
</Button>
</>
)}
{tab === "dns" && (
<>
<Button variant={"secondary"} onClick={() => setTab("domain")}>
Back
</Button>
<Button variant={"primary"} onClick={goToInstall}>
Continue
</Button>
</>
)}
{tab === "install" && (
<>
<Button variant={"secondary"} onClick={() => setTab("dns")}>
Back
</Button>
<Button variant={"primary"} onClick={finishSetup}>
Finish Setup
</Button>
</>
)}
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,20 @@
import CopyToClipboardText from "@components/CopyToClipboardText";
import CircleIcon from "@/assets/icons/CircleIcon";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
type Props = {
cluster: ReverseProxyCluster;
};
export default function SelfHostedProxiesNameCell({ cluster }: Readonly<Props>) {
return (
<div className="flex items-center gap-2.5 ml-2">
<CircleIcon active={true} size={8} inactiveDot={"gray"} />
<CopyToClipboardText
message={`${cluster.address} has been copied to clipboard`}
>
<span className="font-medium">{cluster.address}</span>
</CopyToClipboardText>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import Button from "@components/Button";
import SquareIcon from "@components/SquareIcon";
import { DataTable } from "@components/table/DataTable";
import DataTableHeader from "@components/table/DataTableHeader";
import DataTableRefreshButton from "@components/table/DataTableRefreshButton";
import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import { ColumnDef, SortingState } from "@tanstack/react-table";
import { PlusCircle, ServerIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { ReverseProxyCluster } from "@/interfaces/ReverseProxy";
import useFetchApi from "@/utils/api";
import SelfHostedProxiesActionCell from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesActionCell";
import SelfHostedProxiesConnectedCell from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesConnectedCell";
import { SelfHostedProxiesModal } from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesModal";
import SelfHostedProxiesNameCell from "@/modules/reverse-proxy/self-hosted-proxies/SelfHostedProxiesNameCell";
const ClustersColumns: ColumnDef<ReverseProxyCluster>[] = [
{
accessorKey: "address",
header: ({ column }) => {
return <DataTableHeader column={column}>Proxy Cluster</DataTableHeader>;
},
sortingFn: "text",
cell: ({ row }) => <SelfHostedProxiesNameCell cluster={row.original} />,
},
{
accessorKey: "connected_proxies",
header: ({ column }) => {
return (
<DataTableHeader column={column}>Connected Proxies</DataTableHeader>
);
},
cell: ({ row }) => (
<SelfHostedProxiesConnectedCell cluster={row.original} />
),
},
{
id: "searchString",
accessorFn: (row) => row.address,
},
{
id: "actions",
accessorKey: "address",
header: "",
cell: ({ row }) => <SelfHostedProxiesActionCell cluster={row.original} />,
},
];
type Props = {
headingTarget?: HTMLHeadingElement | null;
};
export default function SelfHostedProxiesTable({
headingTarget,
}: Readonly<Props>) {
const { mutate } = useSWRConfig();
const path = usePathname();
const { permission } = usePermissions();
const { data: clusters, isLoading } = useFetchApi<ReverseProxyCluster[]>(
"/reverse-proxies/clusters",
);
const selfHostedClusters = useMemo(() => {
return clusters?.filter((c) => c.self_hosted) ?? [];
}, [clusters]);
const [addModalOpen, setAddModalOpen] = useState(false);
const [sorting, setSorting] = useLocalStorage<SortingState>(
"netbird-table-sort" + path,
[
{
id: "address",
desc: false,
},
],
);
return (
<>
<SelfHostedProxiesModal
open={addModalOpen}
onOpenChange={setAddModalOpen}
key={addModalOpen ? 1 : 0}
/>
<DataTable
headingTarget={headingTarget}
isLoading={isLoading}
inset={false}
keepStateInLocalStorage={false}
text={"Self-Hosted Proxies"}
sorting={sorting}
setSorting={setSorting}
columns={ClustersColumns}
data={selfHostedClusters}
useRowId={true}
searchPlaceholder={"Search by proxy cluster domain..."}
columnVisibility={{ searchString: false }}
getStartedCard={
<GetStartedTest
icon={
<SquareIcon
icon={<ServerIcon className={"text-nb-gray-200"} size={20} />}
color={"gray"}
size={"large"}
/>
}
title={"Setup Your Own Self-Hosted Proxy Cluster"}
description={
"Setup self-hosted proxies on your own infrastructure for full control over traffic and geographic location."
}
button={
<Button
variant={"primary"}
onClick={() => setAddModalOpen(true)}
disabled={!permission?.services?.create}
>
<PlusCircle size={16} />
Setup Proxy
</Button>
}
/>
}
rightSide={() => (
<>
{selfHostedClusters.length > 0 && (
<Button
variant={"primary"}
className={"ml-auto"}
onClick={() => setAddModalOpen(true)}
disabled={!permission?.services?.create}
>
<PlusCircle size={16} />
Setup Proxy
</Button>
)}
</>
)}
>
{(table) => (
<>
<DataTableRowsPerPage
table={table}
disabled={selfHostedClusters.length === 0}
/>
<DataTableRefreshButton
isDisabled={selfHostedClusters.length === 0}
onClick={() => {
mutate("/reverse-proxies/clusters").then();
}}
/>
</>
)}
</DataTable>
</>
);
}

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,12 +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";
@@ -48,6 +49,12 @@ const AUTH_METHODS: {
},
];
const HEADER_AUTH_METHOD = {
label: "HTTP Headers",
hoverLabel: "HTTP Headers",
Icon: FileCode2Icon,
};
type Props = {
reverseProxy: ReverseProxy;
};
@@ -59,7 +66,6 @@ export default function ReverseProxyAuthCell({
const { openModal } = useReverseProxies();
const { groups } = useGroups();
// L4 services don't support auth
if (isL4Mode(reverseProxy.mode)) {
return (
<div className={"flex"}>
@@ -83,6 +89,8 @@ export default function ReverseProxyAuthCell({
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 ?? [])
@@ -90,103 +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 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>
</>
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 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

@@ -31,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
@@ -42,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}
@@ -76,7 +76,7 @@ export default function ReverseProxyClusterCell({
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

@@ -36,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 && (
<>

View File

@@ -63,56 +63,60 @@ export default function ReverseProxyStatusCell({
if (isActive) return null;
if (hasError) {
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
Something went wrong while setting up this service. See our{" "}
<InlineLink
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
target={"_blank"}
>
Troubleshooting Docs
</InlineLink>{" "}
for more details.
<div 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>
}
align={"center"}
alignOffset={0}
>
<div className={"flex"}>
<Badge variant={"red"}>
<CircleAlert size={11} />
Error
</Badge>
</div>
</FullTooltip>
</FullTooltip>
</div>
);
}
if (isTunnelNotCreated) {
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
The tunnel to the target peer could not be established. See our{" "}
<InlineLink
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
target={"_blank"}
>
Troubleshooting Docs
</InlineLink>{" "}
for more details.
<div 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>
}
align={"center"}
alignOffset={0}
>
<div className={"flex"}>
<Badge variant={"red"}>
<CircleAlert size={11} />
Tunnel not created
</Badge>
</div>
</FullTooltip>
</FullTooltip>
</div>
);
}
return <SettingUpService />;
@@ -120,12 +124,12 @@ export default function ReverseProxyStatusCell({
// HTTP services: hide once active with certificate issued
if (isActive && certificateIssued) {
return null;
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...
@@ -139,7 +143,7 @@ export default function ReverseProxyStatusCell({
const SettingUpService = () => {
return (
<div className={"flex"}>
<div className={"flex"} data-status-cell>
<Badge variant={"yellow"}>
<Loader2 size={14} className={"animate-spin"} />
Setting up service...

View File

@@ -23,6 +23,7 @@ import {
} 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";
@@ -90,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: "",

View File

@@ -1,5 +1,6 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import { wrapIPv6 } from "@utils/ip";
import { PlusCircle, Server } from "lucide-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -20,7 +21,7 @@ export default function ReverseProxyTargetsCell({
if (isL4Mode(reverseProxy.mode)) {
const target = reverseProxy?.targets?.[0];
const address = target.host
? `${target.host}:${target.port}`
? `${wrapIPv6(target.host)}:${target.port}`
: `:${target.port}`;
return (
@@ -36,7 +37,7 @@ export default function ReverseProxyTargetsCell({
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

@@ -13,9 +13,9 @@ type Props = {
export const ReverseProxyTypeCell = ({ reverseProxy }: Props) => {
const serviceModeLabel = useMemo(() => {
if (!reverseProxy?.mode) return "HTTP/S";
if (!reverseProxy?.mode) return "HTTPS";
const mode = SERVICE_MODES[reverseProxy.mode];
if (!mode) return "HTTP/S";
if (!mode) return "HTTPS";
return trim(mode.label.replace("Service", ""));
}, [reverseProxy]);

View File

@@ -20,8 +20,10 @@ export function useReverseProxyAddress(target: Target | undefined) {
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;
if (parts.length !== 2) return false;
const mask = parseInt(parts[1], 10);
const hostMask = resourceAddress.includes(":") ? 128 : 32;
return mask < hostMask;
}, [target?.type, resourceAddress]);
const cidrInfo = useMemo(() => {
@@ -84,13 +86,13 @@ export default function ReverseProxyAddressInput({
value={target?.host ?? ""}
onChange={(e) => {
const host = isHostEditable
? e.target.value.replace(/[^0-9.]/g, "")
? e.target.value.replace(/[^0-9a-fA-F.:]/g, "")
: e.target.value;
onChange((prev) => prev && { ...prev, host });
}}
maxWidthClass={"w-full"}
customSuffix={":"}
placeholder="e.g., 192.168.0.10"
placeholder="e.g., 192.168.0.10 or 2001:db8::1"
disabled={!target}
readOnly={target && !isHostEditable ? true : undefined}
className={cn("rounded-r-none border-r-0", className)}

View File

@@ -461,7 +461,7 @@ export default function ReverseProxyTargetModal({
<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
(e.g. 30s, 2m). <br /> Leave this field empty for no
timeout.
</HelpText>
</div>
@@ -487,7 +487,7 @@ export default function ReverseProxyTargetModal({
<Label>Session Idle Timeout</Label>
<HelpText className={"mb-0"}>
How long a UDP session stays alive without traffic
(max 10m). <br /> Defaults to 30s when empty.
(e.g., 30s, 2m). <br /> Defaults to 30s when empty.
</HelpText>
</div>
<Input

View File

@@ -38,6 +38,7 @@ export default function ReverseProxyFlatTargetActionCell({
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
data-proxy-edit-action={target.proxy.id}
onClick={(e) => {
e.stopPropagation();
if (isL4Mode(target.proxy.mode)) {
@@ -55,6 +56,7 @@ export default function ReverseProxyFlatTargetActionCell({
</DropdownMenuItem>
<DropdownMenuItem
data-proxy-settings-action={target.proxy.id}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: target.proxy, initialTab: "settings" });

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";
@@ -48,13 +49,19 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
const isEnabled = target.proxy.enabled && target.enabled;
return (
<div className={disabled ? "opacity-40" : ""}>
<ReverseProxyNameCell
domain={fullUrl}
enabled={isEnabled}
reverseProxy={row.original.proxy}
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>
);
},
@@ -63,7 +70,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
accessorKey: "arrow",
header: "",
cell: ({ row }) => (
<ReverseProxyArrowCell disabled={!row.original.enabled} />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyArrowCell disabled={row.original.enabled === false} />
</div>
),
},
{
@@ -71,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",
@@ -79,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>
),
},
{
@@ -90,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>
),
},
{
@@ -99,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>
),
},
{
@@ -108,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>
),
},
{

View File

@@ -7,43 +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
const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m
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;
}
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 {
export function validateSessionIdleTimeout(
timeout: string,
): string | undefined {
if (!timeout) return undefined;
if (!DURATION_RE.test(timeout))
return 'Invalid duration, use e.g., "30s", "2m", "5m"';
if (parseDurationMs(timeout) > MAX_SESSION_IDLE_TIMEOUT_MS)
return "Session idle timeout cannot exceed the maximum of 10m.";
return undefined;
}

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

@@ -26,6 +26,7 @@ import InputDomain, { domainReducer } from "@components/ui/InputDomain";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconDirectionSign } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
import { normalizeHostCIDR } from "@utils/ip";
import cidr from "ip-cidr";
import { uniqBy } from "lodash";
import {
@@ -308,7 +309,7 @@ export function RouteModalContent({
enabled: enabled,
peer: useSinglePeer ? routingPeer?.id : undefined,
peer_groups: useSinglePeer ? undefined : peerGroups || undefined,
network: routeType === "ip-range" ? networkRange : undefined,
network: routeType === "ip-range" ? normalizeHostCIDR(networkRange) : undefined,
domains: domainRouteNames,
keep_route: useKeepRoute,
metric: Number(metric) || 9999,
@@ -334,7 +335,7 @@ export function RouteModalContent({
const cidrError = useMemo(() => {
if (networkRange == "") return "";
const validCIDR = cidr.isValidAddress(networkRange);
if (!validCIDR) return "Please enter a valid CIDR, e.g., 192.168.1.0/24";
if (!validCIDR) return "Please enter a valid IP or CIDR, e.g., 192.168.1.1, 192.168.1.0/24 or 2001:db8::/64";
}, [networkRange]);
const isGroupsEntered = useMemo(() => {
@@ -500,11 +501,11 @@ export function RouteModalContent({
)}
>
<Label>Network Range</Label>
<HelpText>Add a private IPv4 address range</HelpText>
<HelpText>Add a private IPv4 or IPv6 address or range</HelpText>
<Input
ref={networkRangeRef}
customPrefix={<NetworkIcon size={16} />}
placeholder={"e.g., 172.16.0.0/16"}
placeholder={"e.g., 172.16.0.1, 172.16.0.0/16, 2001:db8::1 or 2001:db8::/64"}
value={networkRange}
data-cy={"network-range"}
className={"font-mono !text-[13px]"}

View File

@@ -7,6 +7,7 @@ import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { notify } from "@components/Notification";
import Paragraph from "@components/Paragraph";
import { SmallBadge } from "@components/ui/SmallBadge";
import {
Select,
SelectContent,
@@ -22,6 +23,7 @@ import { cn } from "@utils/helpers";
import {
CalendarClock,
ExternalLinkIcon,
KeyRound,
ShieldIcon,
ShieldUserIcon,
TimerResetIcon,
@@ -66,6 +68,15 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
},
);
// Local MFA (UI only, not wired to the backend yet)
const [isLocalMFAEnabled, setIsLocalMFAEnabled] = useState<boolean>(() => {
try {
return account?.settings?.local_mfa_enabled || false;
} catch (error) {
return false;
}
});
// Peer Expiration
const [
loginExpiration,
@@ -105,6 +116,7 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
peerInactivityExpirationEnabled,
peerInactivityExpiresIn,
peerInactivityExpireInterval,
isLocalMFAEnabled,
]);
const saveChanges = async () => {
@@ -129,6 +141,7 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
peer_approval_enabled: peerApproval,
user_approval_required: userApprovalRequired,
},
local_mfa_enabled: isLocalMFAEnabled
},
} as Account)
.then(() => {
@@ -213,6 +226,39 @@ export default function AuthenticationTab({ account }: Readonly<Props>) {
/>
</div>
{!account.settings.local_auth_disabled && account.settings.embedded_idp_enabled ?
(
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={isLocalMFAEnabled}
onChange={setIsLocalMFAEnabled}
dataCy={"local-mfa-enabled"}
label={
<>
<KeyRound size={15} />
Enable Local MFA
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[9px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</>
}
helpText={
<>
Require multi-factor authentication for users
<br />
authenticating with local credentials.
</>
}
disabled={!permission.settings.update}
/>
</div>
) : null
}
<div className={"flex flex-col"}>
<FancyToggleSwitch
value={loginExpiration}

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

@@ -49,6 +49,7 @@ const issuerHints: Partial<Record<SSOIdentityProviderType, string>> = {
okta: "https://{ORG}.okta.com",
entra: "https://login.microsoftonline.com/{TENANT_ID}/v2.0",
pocketid: "https://pocketid.example.com",
adfs: "https://adfs.example.com/adfs",
};
const defaultNames: Record<SSOIdentityProviderType, string> = {
@@ -61,6 +62,7 @@ const defaultNames: Record<SSOIdentityProviderType, string> = {
pocketid: "PocketID",
authentik: "Authentik",
keycloak: "Keycloak",
adfs: "Microsoft AD FS",
};
type Props = {

View File

@@ -49,6 +49,7 @@ export const idpTypeLabels: Record<SSOIdentityProviderType, string> = {
microsoft: "Microsoft",
authentik: "Authentik",
keycloak: "Keycloak",
adfs: "Microsoft AD FS",
};
type ActionCellProps = {

View File

@@ -6,6 +6,7 @@ import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { notify } from "@components/Notification";
import { PeerGroupSelector } from "@components/PeerGroupSelector";
import { useHasChanges } from "@hooks/useHasChanges";
import * as Tabs from "@radix-ui/react-tabs";
import { useApiCall } from "@utils/api";
@@ -18,12 +19,25 @@ import { useSWRConfig } from "swr";
import SettingsIcon from "@/assets/icons/SettingsIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Account } from "@/interfaces/Account";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroups } from "@/contexts/GroupsProvider";
import { SkeletonSettings } from "@components/skeletons/SkeletonSettings";
type Props = {
account: Account;
};
export default function NetworkSettingsTab({ account }: Readonly<Props>) {
const { isLoading: isGroupsLoading } = useGroups();
return isGroupsLoading ? (
<SkeletonSettings />
) : (
<NetworkSettingsTabContent account={account} />
);
}
function NetworkSettingsTabContent({ account }: Readonly<Props>) {
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
@@ -38,6 +52,17 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
const [networkRange, setNetworkRange] = useState(
account.settings.network_range || "",
);
const [networkRangeV6, setNetworkRangeV6] = useState(
account.settings.network_range_v6 || "",
);
const [ipv6EnabledGroups, setIpv6EnabledGroups, { save: saveGroups }] =
useGroupHelper({
initial: account.settings?.ipv6_enabled_groups,
});
const ipv6GroupNames = useMemo(
() => ipv6EnabledGroups.map((g) => g.name).sort(),
[ipv6EnabledGroups],
);
const toggleNetworkDNSSetting = async (toggle: boolean) => {
notify({
@@ -64,19 +89,37 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
const { hasChanges, updateRef } = useHasChanges([
customDNSDomain,
networkRange,
networkRangeV6,
ipv6GroupNames,
]);
const saveChanges = async () => {
const groups = await saveGroups();
const ipv6EnabledGroupIds = groups
.map((group) => group.id)
.filter(Boolean) as string[];
const updatedSettings = {
...account.settings,
ipv6_enabled_groups: ipv6EnabledGroupIds,
};
if (customDNSDomain !== "" || account.settings.dns_domain) {
updatedSettings.dns_domain = customDNSDomain;
}
if (networkRange !== "") {
// Only send network ranges when the user actually changed them, to avoid
// triggering a reallocation when the server hasn't stored an explicit override.
if (networkRange !== (account.settings.network_range || "")) {
updatedSettings.network_range = networkRange;
} else {
delete updatedSettings.network_range;
}
if (networkRangeV6 !== (account.settings.network_range_v6 || "")) {
updatedSettings.network_range_v6 = networkRangeV6;
} else {
delete updatedSettings.network_range_v6;
}
notify({
@@ -89,7 +132,12 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
})
.then(() => {
mutate("/accounts");
updateRef([customDNSDomain, networkRange]);
updateRef([
customDNSDomain,
networkRange,
networkRangeV6,
ipv6GroupNames,
]);
}),
loadingMessage: "Updating network settings...",
});
@@ -124,6 +172,17 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
}
}, [networkRange, account.settings.network_range]);
const networkRangeV6Error = useMemo(() => {
if (networkRangeV6 == "") return "";
if (!networkRangeV6.includes(":") || !cidr.isValidCIDR(networkRangeV6)) {
return "Please enter a valid IPv6 CIDR range, e.g. fd00:1234::/64";
}
const prefixLen = parseInt(networkRangeV6.split("/")[1], 10);
if (prefixLen < 48 || prefixLen > 112) {
return "Prefix length must be between /48 and /112";
}
}, [networkRangeV6]);
return (
<Tabs.Content value={"networks"}>
<div className={"p-default py-6 max-w-2xl"}>
@@ -150,7 +209,8 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
!hasChanges ||
!permission.settings.update ||
!!domainError ||
!!networkRangeError
!!networkRangeError ||
!!networkRangeV6Error
}
onClick={saveChanges}
>
@@ -216,6 +276,51 @@ export default function NetworkSettingsTab({ account }: Readonly<Props>) {
</div>
</div>
<div>
<div
className={
"flex flex-col gap-1 sm:flex-row w-full sm:gap-4 items-center"
}
>
<div className={"min-w-[330px]"}>
<Label>IPv6 Network Range</Label>
<HelpText>
Specify a custom IPv6 range for your network in CIDR format.
All peer IPv6 addresses will be re-allocated when changed.
</HelpText>
</div>
<div className={"w-full"}>
<Input
placeholder={"e.g. fd00:1234:5678::/64"}
errorTooltip={true}
errorTooltipPosition={"top"}
error={networkRangeV6Error}
value={networkRangeV6}
disabled={!permission.settings.update}
onChange={(e) => setNetworkRangeV6(e.target.value)}
/>
</div>
</div>
</div>
<div>
<Label>IPv6 Enabled Groups</Label>
<HelpText>
Peers in the selected groups will receive IPv6 overlay addresses
(dual-stack). Remove all groups to disable IPv6. Changes apply on
save and will restart affected clients.
</HelpText>
<PeerGroupSelector
values={ipv6EnabledGroups}
onChange={setIpv6EnabledGroups}
placeholder="Select groups to enable IPv6..."
showResourceCounter={false}
disabled={!permission.settings.update}
/>
</div>
<div className={"mt-4"} />
<FancyToggleSwitch
value={routingPeerDNSSetting}
onChange={toggleNetworkDNSSetting}

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={{

87
src/utils/ip.test.ts Normal file
View File

@@ -0,0 +1,87 @@
import {
hostSuffixFor,
isIPv4,
isIPv6,
normalizeHostCIDR,
wrapIPv6,
} from "./ip.js";
type Case<T> = { input: string; expected: T; desc?: string };
function run<T>(name: string, cases: Case<T>[], fn: (s: string) => T): number {
console.log(`\n=== ${name} ===`);
let failures = 0;
for (const { input, expected, desc } of cases) {
const actual = fn(input);
const ok = actual === expected;
if (!ok) failures++;
const label = desc ? `${JSON.stringify(input)} (${desc})` : JSON.stringify(input);
console.log(
`${ok ? "✓" : "✗"} ${label.padEnd(40)}${JSON.stringify(actual)}` +
(ok ? "" : ` (expected: ${JSON.stringify(expected)})`),
);
}
return failures;
}
let failures = 0;
failures += run<boolean>("isIPv4", [
{ input: "10.0.0.1", expected: true },
{ input: "192.168.1.0", expected: true },
{ input: "10.0.0.1/32", expected: true, desc: "v4 with prefix" },
{ input: "10.0.0.0/24", expected: true, desc: "v4 subnet" },
{ input: "2001:db8::1", expected: false, desc: "v6" },
{ input: "::1", expected: false, desc: "v6 loopback" },
{ input: "service.internal", expected: false, desc: "domain" },
{ input: "*.example.com", expected: false, desc: "wildcard" },
{ input: "", expected: false },
{ input: "not-an-ip", expected: false },
], isIPv4);
failures += run<boolean>("isIPv6", [
{ input: "2001:db8::1", expected: true },
{ input: "::1", expected: true, desc: "loopback" },
{ input: "::", expected: true, desc: "unspecified" },
{ input: "2620:fe::fe", expected: true, desc: "anycast" },
{ input: "2001:db8::1/128", expected: true, desc: "v6 host CIDR" },
{ input: "2001:db8::/64", expected: true, desc: "v6 subnet" },
{ input: "10.0.0.1", expected: false, desc: "v4" },
{ input: "service.internal", expected: false, desc: "domain" },
{ input: "", expected: false },
], isIPv6);
failures += run<string>("normalizeHostCIDR", [
{ input: "10.0.0.1", expected: "10.0.0.1/32", desc: "bare v4 → /32" },
{ input: "2001:db8::1", expected: "2001:db8::1/128", desc: "bare v6 → /128" },
{ input: "2620:fe::fe", expected: "2620:fe::fe/128" },
{ input: "10.0.0.0/24", expected: "10.0.0.0/24", desc: "v4 CIDR unchanged" },
{ input: "2001:db8::/64", expected: "2001:db8::/64", desc: "v6 CIDR unchanged" },
{ input: "10.0.0.1/32", expected: "10.0.0.1/32", desc: "v4 /32 unchanged" },
{ input: "2001:db8::1/128", expected: "2001:db8::1/128", desc: "v6 /128 unchanged" },
{ input: "service.internal", expected: "service.internal", desc: "domain passthrough" },
{ input: "*.example.com", expected: "*.example.com", desc: "wildcard passthrough" },
{ input: "", expected: "" },
{ input: " 10.0.0.1 ", expected: "10.0.0.1/32", desc: "trims whitespace" },
{ input: "not-an-ip", expected: "not-an-ip", desc: "invalid passthrough" },
], normalizeHostCIDR);
failures += run<number | null>("hostSuffixFor", [
{ input: "10.0.0.1", expected: 32 },
{ input: "2001:db8::1", expected: 128 },
{ input: "service.internal", expected: null, desc: "domain" },
{ input: "", expected: null },
], hostSuffixFor);
failures += run<string>("wrapIPv6", [
{ input: "2001:db8::1", expected: "[2001:db8::1]" },
{ input: "2620:fe::fe", expected: "[2620:fe::fe]" },
{ input: "::1", expected: "[::1]" },
{ input: "[2001:db8::1]", expected: "[2001:db8::1]", desc: "already wrapped" },
{ input: "10.0.0.1", expected: "10.0.0.1", desc: "v4 unchanged" },
{ input: "example.com", expected: "example.com", desc: "domain unchanged" },
{ input: "", expected: "", desc: "empty" },
], wrapIPv6);
console.log(`\n${failures} test(s) failed`);
process.exit(failures > 0 ? 1 : 0);

35
src/utils/ip.ts Normal file
View File

@@ -0,0 +1,35 @@
import { Address4, Address6 } from "ip-address";
export function isIPv6(value: string): boolean {
const bare = value.split("/")[0];
return bare.includes(":") && Address6.isValid(bare);
}
export function isIPv4(value: string): boolean {
const bare = value.split("/")[0];
return !bare.includes(":") && Address4.isValid(bare);
}
// normalizeHostCIDR adds a host-suffix (/32 for IPv4, /128 for IPv6) to bare IP
// addresses. Existing CIDR strings and non-IP values are returned unchanged.
export function normalizeHostCIDR(value: string): string {
const trimmed = value.trim();
if (!trimmed || trimmed.includes("/")) return trimmed;
if (isIPv4(trimmed)) return `${trimmed}/32`;
if (isIPv6(trimmed)) return `${trimmed}/128`;
return trimmed;
}
// hostSuffixFor returns the host suffix (32 or 128) for a given address family.
export function hostSuffixFor(value: string): number | null {
if (isIPv6(value)) return 128;
if (isIPv4(value)) return 32;
return null;
}
// wrapIPv6 wraps a bare IPv6 host in square brackets for use in URL/host:port
// contexts. Bracketed IPv6 ("[...]"), IPv4, and hostnames are returned as-is.
export function wrapIPv6(host: string): string {
if (!host || host.startsWith("[")) return host;
return isIPv6(host) ? `[${host}]` : host;
}

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 = () => {

View File

@@ -94,3 +94,4 @@ export const isNetbirdSSHProtocolSupported = (version: string) => {
if (version == "development") return true;
return compareVersions(version, "0.61.0");
};