Compare commits

...

11 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
81 changed files with 2199 additions and 422 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 ✅"

90
package-lock.json generated
View File

@@ -60,7 +60,7 @@
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.566.0",
"next": "^16.1.7",
"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": {
@@ -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

@@ -68,7 +68,7 @@
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.566.0",
"next": "^16.1.7",
"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

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

@@ -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,6 +15,7 @@ export interface ReverseProxy {
proxy_cluster?: string;
targets: ReverseProxyTarget[];
enabled: boolean;
terminated?: boolean;
pass_host_header?: boolean;
rewrite_redirects?: boolean;
auth?: ReverseProxyAuth;
@@ -22,11 +23,20 @@ export interface ReverseProxy {
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 {
@@ -102,6 +112,7 @@ export interface ReverseProxyDomain {
target_cluster?: string;
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
}
export enum ReverseProxyDomainType {
@@ -149,6 +160,25 @@ export interface ReverseProxyEvent {
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 {

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

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

@@ -214,6 +214,10 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
</PeerProvider>
),
},
{
id: "ipv6",
accessorFn: (row) => row.ipv6,
},
];
type Props = {
@@ -327,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

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useReducer, useRef } from "react";
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,
@@ -18,7 +19,8 @@ import {
SelectOption,
} from "@components/select/SelectDropdown";
import { CountrySelector } from "@/components/ui/CountrySelector";
import { AccessRestrictions } from "@/interfaces/ReverseProxy";
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr";
@@ -93,30 +95,41 @@ function rulesReducer(state: AccessRule[], action: RulesAction): AccessRule[] {
}
}
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[] = [];
restrictions.allowed_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
);
pushCidrRules(rules, restrictions.blocked_cidrs, "block");
restrictions.blocked_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "block", type: "country", value: v }),
);
restrictions.allowed_cidrs?.forEach((v) => {
const isIp = v.endsWith("/32");
rules.push({ id: nextId(), action: "allow", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : v });
});
restrictions.blocked_cidrs?.forEach((v) => {
const isIp = v.endsWith("/32");
rules.push({ id: nextId(), action: "block", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : 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[] = [];
@@ -129,17 +142,23 @@ function rulesToRestrictions(
if (rule.action === "allow") allowed_countries.push(rule.value);
else blocked_countries.push(rule.value);
} else {
const value = rule.type === "ip" && !rule.value.includes("/") ? `${rule.value}/32` : rule.value;
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;
blocked_cidrs.length > 0 ||
hasCrowdSec;
if (!hasAny) return undefined;
@@ -148,6 +167,7 @@ function rulesToRestrictions(
...(blocked_countries.length > 0 && { blocked_countries }),
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
};
}
@@ -155,30 +175,44 @@ type Props = {
value: AccessRestrictions | undefined;
onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean;
};
function validateRule(rule: AccessRule): string {
if (rule.type === "country" || !rule.value) return "";
if (rule.type === "ip") {
const val = rule.value.includes("/") ? rule.value : `${rule.value}/32`;
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";
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";
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 }: Props) => {
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],
@@ -196,8 +230,14 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh
onValidationChangeRef.current = onValidationChange;
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules));
}, [rules]);
if (!supportsCrowdSec) {
setCrowdsecMode(CrowdSecMode.OFF);
}
}, [supportsCrowdSec]);
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
}, [rules, crowdsecMode]);
useEffect(() => {
onValidationChangeRef.current?.(hasErrors);
@@ -205,6 +245,13 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh
return (
<div className={"flex-col flex"}>
{supportsCrowdSec && (
<ReverseProxyCrowdSecIPReputation
value={crowdsecMode}
onChange={setCrowdsecMode}
/>
)}
<div>
<Label>Access Control Rules</Label>
<HelpText>
@@ -270,8 +317,8 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh
<Input
placeholder={
rule.type === "ip"
? "e.g., 85.203.15.42"
: "e.g., 74.125.0.0/16"
? "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) =>
@@ -301,15 +348,17 @@ export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationCh
))}
</div>
)}
<Button
variant="dotted"
className="w-full"
size="sm"
onClick={() => dispatch({ type: "add" })}
>
<PlusIcon size={14} />
Add Rule
</Button>
<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

@@ -590,6 +590,7 @@ export default function ReverseProxyModal({
value={accessRestrictions}
onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec}
/>
</div>
</TabsContent>

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

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

@@ -7,6 +7,8 @@ import {
Mail,
Network,
RectangleEllipsis,
ShieldAlert,
ShieldOff,
Users,
} from "lucide-react";
import * as React from "react";
@@ -69,6 +71,26 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
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

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

@@ -10,6 +10,7 @@ import {
LucideIcon,
NetworkIcon,
Settings,
ShieldAlert,
ShieldCheck,
ShieldOff,
WorkflowIcon,
@@ -19,7 +20,7 @@ import { useMemo } from "react";
import { useCountries } from "@/contexts/CountryProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxy } from "@/interfaces/ReverseProxy";
import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
type RuleEntry = {
key: string;
@@ -37,17 +38,27 @@ export default function ReverseProxyAccessControlCell({
reverseProxy,
}: Readonly<Props>) {
const { permission } = usePermissions();
const { openModal } = useReverseProxies();
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);
(restrictions?.blocked_countries?.length ?? 0) +
(hasCrowdSec ? 1 : 0);
const rulesBadge =
ruleCount > 0 ? (
@@ -92,21 +103,23 @@ export default function ReverseProxyAccessControlCell({
});
}
const isHostCidr = (c: string) =>
c.includes(":") ? c.endsWith("/128") : c.endsWith("/32");
const allowedIps =
restrictions?.allowed_cidrs?.filter((c) => c.endsWith("/32")) ?? [];
restrictions?.allowed_cidrs?.filter(isHostCidr) ?? [];
const allowedCidrs =
restrictions?.allowed_cidrs?.filter((c) => !c.endsWith("/32")) ?? [];
restrictions?.allowed_cidrs?.filter((c) => !isHostCidr(c)) ?? [];
const blockedIps =
restrictions?.blocked_cidrs?.filter((c) => c.endsWith("/32")) ?? [];
restrictions?.blocked_cidrs?.filter(isHostCidr) ?? [];
const blockedCidrs =
restrictions?.blocked_cidrs?.filter((c) => !c.endsWith("/32")) ?? [];
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$/, "")).join(", "),
value: allowedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
});
}
@@ -124,7 +137,7 @@ export default function ReverseProxyAccessControlCell({
key: "blocked-ips",
label: blockedIps.length === 1 ? "Blocked IP" : "Blocked IPs",
Icon: WorkflowIcon,
value: blockedIps.map((c) => c.replace(/\/32$/, "")).join(", "),
value: blockedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
blocked: true,
});
}
@@ -139,8 +152,20 @@ export default function ReverseProxyAccessControlCell({
});
}
if (hasCrowdSec) {
entries.push({
key: "crowdsec",
label: "CrowdSec",
Icon: ShieldAlert,
value:
restrictions?.crowdsec_mode === CrowdSecMode.ENFORCE
? "Enforce"
: "Observe",
});
}
return entries;
}, [restrictions, countries]);
}, [restrictions, countries, hasCrowdSec]);
const showRulesHover = ruleGroups.length > 0;

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

@@ -112,7 +112,9 @@ export default function ReverseProxyAuthCell({
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"}
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>
@@ -122,7 +124,9 @@ export default function ReverseProxyAuthCell({
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"}
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>
@@ -130,15 +134,20 @@ export default function ReverseProxyAuthCell({
) : null;
const showAuthHover =
authCount > 1 || (authCount === 1 && (auth?.bearer_auth?.enabled || hasHeaderAuths));
authCount > 1 ||
(authCount === 1 && (auth?.bearer_auth?.enabled || hasHeaderAuths));
return (
<div className={"flex"} onClick={(e) => {
e.stopPropagation();
if (permission?.services?.update) {
openModal({ proxy: reverseProxy, initialTab: "auth" });
}
}}>
<div
className={"flex"}
data-auth-cell
onClick={(e) => {
e.stopPropagation();
if (permission?.services?.update) {
openModal({ proxy: reverseProxy, initialTab: "auth" });
}
}}
>
<div className={"flex items-center"}>
{authBadge ? (
<HoverCard openDelay={200} closeDelay={100}>
@@ -189,7 +198,15 @@ export default function ReverseProxyAuthCell({
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" : ""}
{
(auth?.header_auths ?? []).filter((h) => h.enabled)
.length
}{" "}
Header
{(auth?.header_auths ?? []).filter((h) => h.enabled)
.length !== 1
? "s"
: ""}
</div>
}
/>
@@ -202,7 +219,9 @@ export default function ReverseProxyAuthCell({
<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"}
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>

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

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

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

@@ -49,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>
);
},
@@ -64,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>
),
},
{
@@ -72,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",
@@ -80,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>
),
},
{
@@ -91,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>
),
},
{
@@ -100,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>
),
},
{
@@ -109,7 +127,9 @@ 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>
),
},
{
@@ -118,7 +138,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
<DataTableHeader column={column}>Access Control</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyAccessControlCell reverseProxy={row.original.proxy} />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyAccessControlCell reverseProxy={row.original.proxy} />
</div>
),
},
{

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