Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7400ac806e | ||
|
|
240ff5af9a | ||
|
|
dc86c30463 | ||
|
|
e58f75ae3c | ||
|
|
dc1adebd27 | ||
|
|
d76cbd1122 | ||
|
|
01330e0f58 | ||
|
|
e9ac1a1a23 | ||
|
|
b53802a5c5 | ||
|
|
9addc18956 | ||
|
|
9701e6503b | ||
|
|
0841caecbb | ||
|
|
c7846760d1 | ||
|
|
8c283b6ef9 | ||
|
|
34ae3b4da6 |
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal 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
105
.github/workflows/docs-ack.yml
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
name: Docs Acknowledgement
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
docs-ack:
|
||||
name: Require docs PR URL or explicit "not needed"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Read PR body
|
||||
id: body
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BODY_B64=$(jq -r '.pull_request.body // "" | @base64' "$GITHUB_EVENT_PATH")
|
||||
{
|
||||
echo "body_b64=$BODY_B64"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate checkbox selection
|
||||
id: validate
|
||||
shell: bash
|
||||
env:
|
||||
BODY_B64: ${{ steps.body.outputs.body_b64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! body="$(printf '%s' "$BODY_B64" | base64 -d)"; then
|
||||
echo "::error::Failed to decode PR body from base64. Data may be corrupted or missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
added_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*I added/updated documentation' | wc -l | tr -d '[:space:]' || true)
|
||||
noneed_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*Documentation is \*\*not needed\*\*' | wc -l | tr -d '[:space:]' || true)
|
||||
|
||||
total=$((added_checked + noneed_checked))
|
||||
if [ "$total" -ne 1 ]; then
|
||||
echo "::error::You must check exactly one docs option in the PR template (either 'docs added' OR 'not needed')."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$added_checked" -eq 1 ]; then
|
||||
echo "mode=added" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "mode=noneed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract docs PR URL (when 'docs added')
|
||||
if: steps.validate.outputs.mode == 'added'
|
||||
id: extract
|
||||
shell: bash
|
||||
env:
|
||||
BODY_B64: ${{ steps.body.outputs.body_b64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body="$(printf '%s' "$BODY_B64" | base64 -d)"
|
||||
|
||||
# Strictly require HTTPS and that it's a PR in netbirdio/docs
|
||||
# e.g., https://github.com/netbirdio/docs/pull/1234
|
||||
url="$(printf '%s' "$body" | grep -Eo 'https://github\.com/netbirdio/docs/pull/[0-9]+' | head -n1 || true)"
|
||||
|
||||
if [ -z "${url:-}" ]; then
|
||||
echo "::error::You checked 'docs added' but didn't include a valid HTTPS PR link to netbirdio/docs (e.g., https://github.com/netbirdio/docs/pull/1234)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pr_number="$(printf '%s' "$url" | sed -E 's#.*/pull/([0-9]+)$#\1#')"
|
||||
{
|
||||
echo "url=$url"
|
||||
echo "pr_number=$pr_number"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify docs PR exists (and is open or merged)
|
||||
if: steps.validate.outputs.mode == 'added'
|
||||
uses: actions/github-script@v7
|
||||
id: verify
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.extract.outputs.pr_number }}
|
||||
with:
|
||||
script: |
|
||||
const prNumber = parseInt(process.env.PR_NUMBER, 10);
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner: 'netbirdio',
|
||||
repo: 'docs',
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
// Allow open or merged PRs
|
||||
const ok = data.state === 'open' || data.merged === true;
|
||||
core.setOutput('state', data.state);
|
||||
core.setOutput('merged', String(!!data.merged));
|
||||
if (!ok) {
|
||||
core.setFailed(`Docs PR #${prNumber} exists but is neither open nor merged (state=${data.state}, merged=${data.merged}).`);
|
||||
}
|
||||
result-encoding: string
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: All good
|
||||
run: echo "Documentation requirement satisfied ✅"
|
||||
98
package-lock.json
generated
98
package-lock.json
generated
@@ -59,8 +59,8 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^16.1.7",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
@@ -3244,32 +3244,6 @@
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -3998,10 +3972,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.19",
|
||||
@@ -4025,13 +4002,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -4320,12 +4299,6 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -6889,9 +6862,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -6923,9 +6896,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.562.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
|
||||
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
|
||||
"version": "0.566.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.566.0.tgz",
|
||||
"integrity": "sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -6973,15 +6946,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
@@ -7412,9 +7388,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -8777,9 +8753,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
|
||||
@@ -67,8 +67,8 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^16.1.7",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
@@ -89,6 +89,9 @@
|
||||
"timescape": "^0.7.1",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": ">=10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />,
|
||||
};
|
||||
|
||||
|
||||
BIN
src/assets/integrations/crowdsec.png
Normal file
BIN
src/assets/integrations/crowdsec.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -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"],
|
||||
|
||||
@@ -74,7 +74,7 @@ export const buttonVariants = cva(
|
||||
"",
|
||||
],
|
||||
"danger-text": [
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50",
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
|
||||
@@ -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={() =>
|
||||
|
||||
129
src/components/CardTable.tsx
Normal file
129
src/components/CardTable.tsx
Normal 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;
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -98,7 +98,7 @@ const SelectItem = React.forwardRef<
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
|
||||
@@ -48,6 +48,9 @@ interface SelectDropdownProps {
|
||||
children?: React.ReactNode;
|
||||
maxHeight?: number;
|
||||
triggerClassName?: string;
|
||||
iconSize?: number;
|
||||
truncate?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
@@ -68,6 +71,9 @@ export function SelectDropdown({
|
||||
children,
|
||||
maxHeight,
|
||||
triggerClassName,
|
||||
iconSize = 14,
|
||||
truncate = false,
|
||||
compact = false,
|
||||
}: Readonly<SelectDropdownProps>) {
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
@@ -107,15 +113,18 @@ export function SelectDropdown({
|
||||
|
||||
const SelectedItem = () => {
|
||||
return (
|
||||
<div className={"flex items-center gap-2.5"}>
|
||||
{selected?.icon && <selected.icon size={14} width={14} />}
|
||||
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
|
||||
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col text-sm font-medium",
|
||||
size === "xs" && "text-xs",
|
||||
truncate && "min-w-0",
|
||||
)}
|
||||
>
|
||||
<span className={"text-nb-gray-200"}>{selected?.label}</span>
|
||||
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
|
||||
{selected?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -216,20 +225,22 @@ export function SelectDropdown({
|
||||
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
|
||||
!showSearch && "pt-2",
|
||||
"overflow-y-auto flex flex-col gap-1",
|
||||
compact ? "pl-1 pr-1" : "pl-2 pr-3",
|
||||
!showSearch && (compact ? "pt-1" : "pt-2"),
|
||||
)}
|
||||
style={{
|
||||
maxHeight: maxHeight ?? 380,
|
||||
}}
|
||||
>
|
||||
<CommandGroup>
|
||||
<div className={"grid grid-cols-1 gap-1 pb-2 w-full"}>
|
||||
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
|
||||
{filteredItems.map((option) => (
|
||||
<SelectDropdownItem
|
||||
option={option}
|
||||
toggle={toggle}
|
||||
key={option.value}
|
||||
iconSize={iconSize}
|
||||
showValue={showValues}
|
||||
size={size}
|
||||
/>
|
||||
@@ -249,11 +260,13 @@ const SelectDropdownItem = ({
|
||||
toggle,
|
||||
showValue = false,
|
||||
size = "sm",
|
||||
iconSize = 14,
|
||||
}: {
|
||||
option: SelectOption;
|
||||
toggle: (value: string) => void;
|
||||
showValue?: boolean;
|
||||
size: "xs" | "sm";
|
||||
iconSize?: number;
|
||||
}) => {
|
||||
const value = option.value || "" + option.label || "";
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
@@ -285,7 +298,12 @@ const SelectDropdownItem = ({
|
||||
option?.disabled && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{option.icon && <option.icon size={14} width={14} />}
|
||||
{option.icon && (
|
||||
<div className={"shrink-0"}>
|
||||
<option.icon size={iconSize} width={iconSize} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{option?.renderItem && option.renderItem()}
|
||||
{!option?.renderItem && (
|
||||
<div
|
||||
|
||||
@@ -53,6 +53,7 @@ declare module "@tanstack/table-core" {
|
||||
}
|
||||
interface SortingFns {
|
||||
checkbox: SortingFn<unknown>;
|
||||
datetime: SortingFn<unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +100,15 @@ const arrIncludesSomeExact: FilterFn<any> = (
|
||||
return value.some((val) => val === rowValue);
|
||||
};
|
||||
|
||||
const datetimeSort: SortingFn<any> = (rowA, rowB, columnId) => {
|
||||
const aConnected = rowA.original?.connected;
|
||||
const bConnected = rowB.original?.connected;
|
||||
if (aConnected !== bConnected) return aConnected ? 1 : -1;
|
||||
const a = dayjs(rowA.getValue(columnId)).valueOf();
|
||||
const b = dayjs(rowB.getValue(columnId)).valueOf();
|
||||
return a - b;
|
||||
};
|
||||
|
||||
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
|
||||
const valueA =
|
||||
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
|
||||
@@ -324,6 +334,7 @@ export function DataTable<TData, TValue>({
|
||||
},
|
||||
sortingFns: {
|
||||
checkbox: checkboxSort,
|
||||
datetime: datetimeSort,
|
||||
},
|
||||
getRowId: useRowId ? (row) => row.id : undefined,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props = {
|
||||
center?: boolean;
|
||||
className?: string;
|
||||
sorting?: boolean;
|
||||
onSort?: () => void;
|
||||
name?: string;
|
||||
};
|
||||
export default function DataTableHeader({
|
||||
@@ -23,14 +24,20 @@ export default function DataTableHeader({
|
||||
center,
|
||||
className,
|
||||
sorting = true,
|
||||
onSort,
|
||||
name,
|
||||
}: Props) {
|
||||
const serverPagination = useOptionalServerPagination();
|
||||
|
||||
const handleSort = () => {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
column.toggleSorting(direction === "desc");
|
||||
if (onSort) {
|
||||
onSort();
|
||||
} else {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
column.toggleSorting(direction === "desc");
|
||||
}
|
||||
if (name && serverPagination?.setSort) {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
serverPagination.setSort(name, direction);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,8 +9,11 @@ import { useCountries } from "@/contexts/CountryProvider";
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
iconSize?: number;
|
||||
popoverWidth?: "auto" | "content" | number;
|
||||
truncate?: boolean;
|
||||
};
|
||||
export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
|
||||
const { countries, isLoading } = useCountries();
|
||||
|
||||
const countryList = useMemo(() => {
|
||||
@@ -22,7 +25,7 @@ export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
}) =>
|
||||
createElement(RoundedFlag, {
|
||||
country: country.country_code,
|
||||
size: 20,
|
||||
size: iconSize,
|
||||
...props,
|
||||
});
|
||||
return {
|
||||
@@ -42,7 +45,10 @@ export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
searchPlaceholder={"Search country..."}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
iconSize={iconSize}
|
||||
options={countryList || []}
|
||||
popoverWidth={popoverWidth}
|
||||
truncate={truncate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,11 @@ const CountryContext = React.createContext(
|
||||
countries: Country[] | undefined;
|
||||
isLoading: boolean;
|
||||
getRegionByPeer: (peer: Peer) => string;
|
||||
getRegionText: (country_code: string, city_name: string) => string;
|
||||
getRegionText: (
|
||||
country_code: string,
|
||||
city_name: string,
|
||||
subdivision_code?: string,
|
||||
) => string;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,7 +25,11 @@ export default function CountryProvider({ children }: Props) {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
const getRegionByPeer = (peer: Peer) => "Unknown";
|
||||
const getRegionText = (country_code: string, city_name: string) => "Unknown";
|
||||
const getRegionText = (
|
||||
country_code: string,
|
||||
city_name: string,
|
||||
subdivision_code?: string,
|
||||
) => "Unknown";
|
||||
|
||||
return isRestricted ? (
|
||||
<CountryContext.Provider
|
||||
@@ -47,12 +55,14 @@ function CountryProviderContent({ children }: Props) {
|
||||
);
|
||||
|
||||
const getRegionText = useCallback(
|
||||
(country_code: string, city_name: string) => {
|
||||
(country_code: string, city_name: string, subdivision_code?: string) => {
|
||||
if (!countries) return "Unknown";
|
||||
const country = countries.find((c) => c.country_code === country_code);
|
||||
if (!country) return "Unknown";
|
||||
if (!city_name) return country.country_name;
|
||||
return `${country.country_name}, ${city_name}`;
|
||||
const parts = [country.country_name];
|
||||
if (subdivision_code) parts.push(subdivision_code);
|
||||
if (city_name) parts.push(city_name);
|
||||
return parts.join(", ");
|
||||
},
|
||||
[countries],
|
||||
);
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
17
src/hooks/useEmbeddedIdentityProviders.ts
Normal file
17
src/hooks/useEmbeddedIdentityProviders.ts
Normal 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 };
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Peer {
|
||||
id?: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
ipv6?: string;
|
||||
connected: boolean;
|
||||
created_at?: Date;
|
||||
last_seen: Date;
|
||||
|
||||
@@ -15,12 +15,30 @@ export interface ReverseProxy {
|
||||
proxy_cluster?: string;
|
||||
targets: ReverseProxyTarget[];
|
||||
enabled: boolean;
|
||||
terminated?: boolean;
|
||||
pass_host_header?: boolean;
|
||||
rewrite_redirects?: boolean;
|
||||
auth?: ReverseProxyAuth;
|
||||
access_restrictions?: AccessRestrictions;
|
||||
meta?: ReverseProxyMeta;
|
||||
}
|
||||
|
||||
export const CrowdSecMode = {
|
||||
OFF: "off",
|
||||
ENFORCE: "enforce",
|
||||
OBSERVE: "observe",
|
||||
} as const;
|
||||
|
||||
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
|
||||
|
||||
export interface AccessRestrictions {
|
||||
allowed_cidrs?: string[];
|
||||
blocked_cidrs?: string[];
|
||||
allowed_countries?: string[];
|
||||
blocked_countries?: string[];
|
||||
crowdsec_mode?: CrowdSecMode;
|
||||
}
|
||||
|
||||
export interface ReverseProxyMeta {
|
||||
created_at: string;
|
||||
status: ReverseProxyStatus;
|
||||
@@ -77,6 +95,13 @@ export interface ReverseProxyAuth {
|
||||
link_auth?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
header_auths?: HeaderAuthConfig[];
|
||||
}
|
||||
|
||||
export interface HeaderAuthConfig {
|
||||
enabled: boolean;
|
||||
header: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ReverseProxyDomain {
|
||||
@@ -86,6 +111,8 @@ export interface ReverseProxyDomain {
|
||||
type: ReverseProxyDomainType;
|
||||
target_cluster?: string;
|
||||
supports_custom_ports?: boolean;
|
||||
require_subdomain?: boolean;
|
||||
supports_crowdsec?: boolean;
|
||||
}
|
||||
|
||||
export enum ReverseProxyDomainType {
|
||||
@@ -129,9 +156,29 @@ export interface ReverseProxyEvent {
|
||||
auth_method_used?: string;
|
||||
country_code?: string;
|
||||
city_name?: string;
|
||||
subdivision_code?: string;
|
||||
bytes_upload: number;
|
||||
bytes_download: number;
|
||||
protocol?: EventProtocol;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ReverseProxyCluster {
|
||||
id?: string;
|
||||
address: string;
|
||||
connected_proxies: number;
|
||||
self_hosted: boolean;
|
||||
}
|
||||
|
||||
export interface ReverseProxyClusterToken {
|
||||
id?: string;
|
||||
name: string;
|
||||
plain_token?: string;
|
||||
expires_at?: string;
|
||||
expires_in?: number;
|
||||
created_at?: string;
|
||||
last_used?: string;
|
||||
revoked?: boolean;
|
||||
}
|
||||
|
||||
export function isL4Event(event: ReverseProxyEvent): boolean {
|
||||
@@ -181,5 +228,8 @@ export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
|
||||
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
|
||||
|
||||
export const REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy";
|
||||
|
||||
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";
|
||||
|
||||
@@ -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" : "",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -91,10 +91,11 @@ export function DNSZoneModalContent({
|
||||
if (domain == "") return "";
|
||||
const valid = validator.isValidDomain(domain, {
|
||||
allowWildcard: false,
|
||||
allowOnlyTld: false,
|
||||
allowOnlyTld: true,
|
||||
preventLeadingAndTrailingDots: true,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. company.internal or intra.example.com";
|
||||
return "Please enter a valid domain, e.g. internal, company.internal or intra.example.com";
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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;
|
||||
|
||||
118
src/modules/peer/PeerEditIPModal.tsx
Normal file
118
src/modules/peer/PeerEditIPModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { PeerAddressTooltipContent } from "@/modules/peers/PeerAddressTooltipCon
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
export default function PeerAddressCell({ peer }: Props) {
|
||||
return (
|
||||
<FullTooltip
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -138,8 +138,18 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: "last_seen",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Last seen</DataTableHeader>;
|
||||
header: ({ column, table }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
onSort={() => {
|
||||
const desc = column.getIsSorted() === "desc";
|
||||
table.setSorting([{ id: "last_seen", desc: !desc }]);
|
||||
}}
|
||||
>
|
||||
Last seen
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
|
||||
@@ -204,6 +214,10 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
</PeerProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "ipv6",
|
||||
accessorFn: (row) => row.ipv6,
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
@@ -226,17 +240,13 @@ export default function PeersTable({
|
||||
"netbird-table-sort" + path,
|
||||
[
|
||||
{
|
||||
id: "connected",
|
||||
id: "last_seen",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
{
|
||||
id: "last_seen",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
@@ -321,6 +331,7 @@ export default function PeersTable({
|
||||
connect: permission.peers.update,
|
||||
groups: permission.groups.read,
|
||||
os: false,
|
||||
ipv6: false,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={<NoPeersGettingStarted showBackground={true} />}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
[],
|
||||
|
||||
364
src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Normal file
364
src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import Button from "@components/Button";
|
||||
import { Input } from "@components/Input";
|
||||
import cidr from "ip-cidr";
|
||||
import { isIPv6 } from "@utils/ip";
|
||||
import {
|
||||
FlagIcon,
|
||||
MinusCircleIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldXIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import { CountrySelector } from "@/components/ui/CountrySelector";
|
||||
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
|
||||
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
|
||||
|
||||
type AccessAction = "allow" | "block";
|
||||
type AccessRuleType = "country" | "ip" | "cidr";
|
||||
|
||||
const ACTION_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
label: "Allow Only",
|
||||
value: "allow",
|
||||
icon: (props) => <ShieldCheckIcon {...props} className="text-green-500" />,
|
||||
},
|
||||
{
|
||||
label: "Block Only",
|
||||
value: "block",
|
||||
icon: (props) => <ShieldXIcon {...props} className="text-red-500" />,
|
||||
},
|
||||
];
|
||||
|
||||
const TYPE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
label: "Country",
|
||||
value: "country",
|
||||
icon: (props) => <FlagIcon {...props} />,
|
||||
},
|
||||
{
|
||||
label: "IP Address",
|
||||
value: "ip",
|
||||
icon: (props) => <WorkflowIcon {...props} />,
|
||||
},
|
||||
{
|
||||
label: "CIDR Block",
|
||||
value: "cidr",
|
||||
icon: (props) => <NetworkIcon {...props} />,
|
||||
},
|
||||
];
|
||||
|
||||
type AccessRule = {
|
||||
id: string;
|
||||
action: AccessAction;
|
||||
type: AccessRuleType;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type RulesAction =
|
||||
| { type: "add" }
|
||||
| { type: "remove"; id: string }
|
||||
| {
|
||||
type: "update";
|
||||
id: string;
|
||||
field: "action" | "type" | "value";
|
||||
value: string;
|
||||
};
|
||||
|
||||
const nextId = () => crypto.randomUUID();
|
||||
|
||||
function rulesReducer(state: AccessRule[], action: RulesAction): AccessRule[] {
|
||||
switch (action.type) {
|
||||
case "add":
|
||||
return [
|
||||
...state,
|
||||
{ id: nextId(), action: "allow", type: "country", value: "" },
|
||||
];
|
||||
case "remove":
|
||||
return state.filter((r) => r.id !== action.id);
|
||||
case "update":
|
||||
return state.map((r) => {
|
||||
if (r.id !== action.id) return r;
|
||||
if (action.field === "type") {
|
||||
return { ...r, type: action.value as AccessRuleType, value: "" };
|
||||
}
|
||||
return { ...r, [action.field]: action.value };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pushCidrRules(
|
||||
rules: AccessRule[],
|
||||
values: string[] | undefined,
|
||||
action: AccessAction,
|
||||
) {
|
||||
values?.forEach((v) => {
|
||||
const isIp = v.includes(":") ? v.endsWith("/128") : v.endsWith("/32");
|
||||
rules.push({
|
||||
id: nextId(),
|
||||
action,
|
||||
type: isIp ? "ip" : "cidr",
|
||||
value: isIp ? v.replace(/\/(32|128)$/, "") : v,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function restrictionsToRules(
|
||||
restrictions: AccessRestrictions | undefined,
|
||||
): AccessRule[] {
|
||||
if (!restrictions) return [];
|
||||
const rules: AccessRule[] = [];
|
||||
pushCidrRules(rules, restrictions.blocked_cidrs, "block");
|
||||
restrictions.blocked_countries?.forEach((v) =>
|
||||
rules.push({ id: nextId(), action: "block", type: "country", value: v }),
|
||||
);
|
||||
pushCidrRules(rules, restrictions.allowed_cidrs, "allow");
|
||||
restrictions.allowed_countries?.forEach((v) =>
|
||||
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
|
||||
);
|
||||
return rules;
|
||||
}
|
||||
|
||||
function rulesToRestrictions(
|
||||
rules: AccessRule[],
|
||||
crowdsecMode?: CrowdSecMode,
|
||||
): AccessRestrictions | undefined {
|
||||
const allowed_countries: string[] = [];
|
||||
const blocked_countries: string[] = [];
|
||||
const allowed_cidrs: string[] = [];
|
||||
const blocked_cidrs: string[] = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.value) continue;
|
||||
if (rule.type === "country") {
|
||||
if (rule.action === "allow") allowed_countries.push(rule.value);
|
||||
else blocked_countries.push(rule.value);
|
||||
} else {
|
||||
const suffix = rule.value.includes(":") ? "/128" : "/32";
|
||||
const value =
|
||||
rule.type === "ip" && !rule.value.includes("/")
|
||||
? `${rule.value}${suffix}`
|
||||
: rule.value;
|
||||
if (rule.action === "allow") allowed_cidrs.push(value);
|
||||
else blocked_cidrs.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
|
||||
const hasAny =
|
||||
allowed_countries.length > 0 ||
|
||||
blocked_countries.length > 0 ||
|
||||
allowed_cidrs.length > 0 ||
|
||||
blocked_cidrs.length > 0 ||
|
||||
hasCrowdSec;
|
||||
|
||||
if (!hasAny) return undefined;
|
||||
|
||||
return {
|
||||
...(allowed_countries.length > 0 && { allowed_countries }),
|
||||
...(blocked_countries.length > 0 && { blocked_countries }),
|
||||
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
|
||||
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
|
||||
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: AccessRestrictions | undefined;
|
||||
onChange: (value: AccessRestrictions | undefined) => void;
|
||||
onValidationChange?: (hasErrors: boolean) => void;
|
||||
supportsCrowdSec?: boolean;
|
||||
};
|
||||
|
||||
function validateRule(rule: AccessRule): string {
|
||||
if (rule.type === "country" || !rule.value) return "";
|
||||
if (rule.type === "ip") {
|
||||
let val = rule.value;
|
||||
if (!val.includes("/")) {
|
||||
const suffix = isIPv6(val) ? 128 : 32;
|
||||
val = `${val}/${suffix}`;
|
||||
}
|
||||
if (!cidr.isValidAddress(val)) {
|
||||
return "Please enter a valid IP address, e.g., 85.203.15.42 or 2001:db8::1";
|
||||
}
|
||||
} else {
|
||||
if (!rule.value.includes("/") || !cidr.isValidAddress(rule.value)) {
|
||||
return "Please enter a valid CIDR block, e.g., 74.125.0.0/16 or 2001:db8::/64";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export const ReverseProxyAccessControlRules = ({
|
||||
value,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
supportsCrowdSec,
|
||||
}: Props) => {
|
||||
const [rules, dispatch] = useReducer(
|
||||
rulesReducer,
|
||||
value,
|
||||
restrictionsToRules,
|
||||
);
|
||||
|
||||
const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
|
||||
value?.crowdsec_mode ?? CrowdSecMode.OFF,
|
||||
);
|
||||
|
||||
const errors = useMemo(
|
||||
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
|
||||
[rules],
|
||||
);
|
||||
|
||||
const hasErrors = useMemo(
|
||||
() => Object.values(errors).some((e) => e !== ""),
|
||||
[errors],
|
||||
);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const onValidationChangeRef = useRef(onValidationChange);
|
||||
onValidationChangeRef.current = onValidationChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!supportsCrowdSec) {
|
||||
setCrowdsecMode(CrowdSecMode.OFF);
|
||||
}
|
||||
}, [supportsCrowdSec]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
|
||||
}, [rules, crowdsecMode]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChangeRef.current?.(hasErrors);
|
||||
}, [hasErrors]);
|
||||
|
||||
return (
|
||||
<div className={"flex-col flex"}>
|
||||
{supportsCrowdSec && (
|
||||
<ReverseProxyCrowdSecIPReputation
|
||||
value={crowdsecMode}
|
||||
onChange={setCrowdsecMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Access Control Rules</Label>
|
||||
<HelpText>
|
||||
Define rules to allow or block traffic based on country, IP address,
|
||||
or CIDR block.
|
||||
<br />
|
||||
Block rules always take priority over allow rules.
|
||||
</HelpText>
|
||||
</div>
|
||||
{rules.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mt-1 mb-4">
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center">
|
||||
<div className="w-[160px] shrink-0 [&_button]:rounded-r-none [&_button]:w-[160px]">
|
||||
<SelectDropdown
|
||||
value={rule.action}
|
||||
onChange={(v) =>
|
||||
dispatch({
|
||||
type: "update",
|
||||
id: rule.id,
|
||||
field: "action",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
options={ACTION_OPTIONS}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[160px] shrink-0 -ml-px [&_button]:rounded-none [&_button]:w-[160px]">
|
||||
<SelectDropdown
|
||||
value={rule.type}
|
||||
onChange={(v) =>
|
||||
dispatch({
|
||||
type: "update",
|
||||
id: rule.id,
|
||||
field: "type",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
options={TYPE_OPTIONS}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 -ml-px [&_button]:rounded-l-none [&_input]:rounded-l-none">
|
||||
{rule.type === "country" ? (
|
||||
<CountrySelector
|
||||
iconSize={16}
|
||||
popoverWidth={350}
|
||||
truncate
|
||||
value={rule.value}
|
||||
onChange={(v) =>
|
||||
dispatch({
|
||||
type: "update",
|
||||
id: rule.id,
|
||||
field: "value",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={
|
||||
rule.type === "ip"
|
||||
? "e.g., 85.203.15.42 or 2001:db8::1"
|
||||
: "e.g., 74.125.0.0/16 or 2001:db8::/64"
|
||||
}
|
||||
value={rule.value}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: "update",
|
||||
id: rule.id,
|
||||
field: "value",
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
error={errors[rule.id]}
|
||||
errorTooltip={true}
|
||||
maxWidthClass="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default-outline"
|
||||
className="h-[42px] w-[42px] !px-0 shrink-0 ml-2"
|
||||
onClick={() => dispatch({ type: "remove", id: rule.id })}
|
||||
aria-label="Remove rule"
|
||||
>
|
||||
<MinusCircleIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="dotted"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={() => dispatch({ type: "add" })}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
src/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation.tsx
Normal file
109
src/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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{" "}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
Binary,
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
FileCode2Icon,
|
||||
GlobeIcon,
|
||||
LockKeyhole,
|
||||
MapPinned,
|
||||
PlusCircle,
|
||||
RectangleEllipsis,
|
||||
Settings,
|
||||
ShieldCheckIcon,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -37,7 +39,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
AccessRestrictions,
|
||||
HeaderAuthConfig,
|
||||
isL4Mode as isL4ServiceMode,
|
||||
REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
|
||||
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
|
||||
REVERSE_PROXY_SERVICES_DOCS_LINK,
|
||||
REVERSE_PROXY_SETTINGS_DOCS_LINK,
|
||||
@@ -53,6 +58,7 @@ import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput";
|
||||
import { useReverseProxyDomain } from "./domain/useReverseProxyDomain";
|
||||
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
|
||||
import AuthHeaderModal from "@/modules/reverse-proxy/auth/AuthHeaderModal";
|
||||
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
|
||||
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
|
||||
import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets";
|
||||
@@ -61,14 +67,15 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
|
||||
import { type Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
|
||||
import { useReverseProxyAddress } from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
|
||||
import {
|
||||
validateTimeout,
|
||||
validateSessionIdleTimeout,
|
||||
validateTimeout,
|
||||
} from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import {
|
||||
ReverseProxyServiceModeSelector,
|
||||
SERVICE_MODES,
|
||||
} from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
|
||||
import { ReverseProxyAccessControlRules } from "@/modules/reverse-proxy/ReverseProxyAccessControlRules";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -236,10 +243,24 @@ export default function ReverseProxyModal({
|
||||
reverseProxy?.auth?.link_auth?.enabled ?? false,
|
||||
);
|
||||
|
||||
const [headerAuthsEnabled, setHeaderAuthsEnabled] = useState(
|
||||
(reverseProxy?.auth?.header_auths ?? []).some((h) => h.enabled),
|
||||
);
|
||||
const [headerAuths, setHeaderAuths] = useState<HeaderAuthConfig[]>(
|
||||
reverseProxy?.auth?.header_auths ?? [],
|
||||
);
|
||||
|
||||
const [accessRestrictions, setAccessRestrictions] = useState<
|
||||
AccessRestrictions | undefined
|
||||
>(reverseProxy?.access_restrictions);
|
||||
|
||||
const [accessControlHasErrors, setAccessControlHasErrors] = useState(false);
|
||||
|
||||
// Auth modal states
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [ssoModalOpen, setSsoModalOpen] = useState(false);
|
||||
const [pinModalOpen, setPinModalOpen] = useState(false);
|
||||
const [headerModalOpen, setHeaderModalOpen] = useState(false);
|
||||
|
||||
// Target being added/edited
|
||||
const [targetModalOpen, setTargetModalOpen] = useState(false);
|
||||
@@ -248,8 +269,12 @@ export default function ReverseProxyModal({
|
||||
);
|
||||
|
||||
const canContinueToSettings = useMemo(() => {
|
||||
const subdomainRequired =
|
||||
selectedDomain?.require_subdomain === true;
|
||||
const isSubdomainValid =
|
||||
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists;
|
||||
baseDomain.length > 0 &&
|
||||
!domainAlreadyExists &&
|
||||
(subdomain.length > 0 || !subdomainRequired);
|
||||
const isValidPort = (port: number) => port >= 1 && port <= 65535;
|
||||
const hasHttpEndpoint = !isL4Mode && targets.length > 0;
|
||||
const hasL4Endpoint =
|
||||
@@ -264,6 +289,7 @@ export default function ReverseProxyModal({
|
||||
subdomain,
|
||||
baseDomain,
|
||||
domainAlreadyExists,
|
||||
selectedDomain,
|
||||
serviceMode,
|
||||
targets.length,
|
||||
isL4Mode,
|
||||
@@ -305,16 +331,20 @@ export default function ReverseProxyModal({
|
||||
);
|
||||
};
|
||||
|
||||
const hasNoAuth =
|
||||
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
|
||||
const isUnprotected =
|
||||
!passwordEnabled &&
|
||||
!pinEnabled &&
|
||||
!bearerEnabled &&
|
||||
!linkAuthEnabled &&
|
||||
!headerAuthsEnabled &&
|
||||
!accessRestrictions;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Show warning if no authentication is configured (HTTP only; TLS is pass-through)
|
||||
if (!isL4Mode && hasNoAuth) {
|
||||
if (isUnprotected) {
|
||||
const confirmed = await confirm({
|
||||
title: "No Authentication Configured",
|
||||
title: "No Protection Configured",
|
||||
description:
|
||||
"This service will be publicly accessible to everyone on the internet without any restrictions. Are you sure you want to continue?",
|
||||
"This service has no authentication or access control rules configured. It will be publicly accessible to everyone on the internet. Are you sure you want to continue?",
|
||||
type: "warning",
|
||||
confirmText: reverseProxy ? "Save Changes" : "Add Service",
|
||||
cancelText: "Cancel",
|
||||
@@ -341,6 +371,9 @@ export default function ReverseProxyModal({
|
||||
link_auth: {
|
||||
enabled: linkAuthEnabled,
|
||||
},
|
||||
header_auths: headerAuthsEnabled
|
||||
? headerAuths.map((h) => ({ ...h, enabled: true }))
|
||||
: [],
|
||||
};
|
||||
|
||||
const l4TargetPayload: ReverseProxyTarget | undefined = l4Target
|
||||
@@ -383,6 +416,7 @@ export default function ReverseProxyModal({
|
||||
pass_host_header: isL4Mode ? undefined : passHostHeader,
|
||||
rewrite_redirects: isL4Mode ? undefined : rewriteRedirects,
|
||||
auth: isL4Mode ? undefined : auth,
|
||||
access_restrictions: accessRestrictions,
|
||||
},
|
||||
proxyId: reverseProxy?.id,
|
||||
onSuccess: () => {
|
||||
@@ -426,10 +460,17 @@ export default function ReverseProxyModal({
|
||||
</TabsTrigger>
|
||||
{!isL4Mode && (
|
||||
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
|
||||
<LockKeyhole size={16} />
|
||||
<LockKeyhole size={14} />
|
||||
Authentication
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value={"access-control"}
|
||||
disabled={!canContinueToSettings}
|
||||
>
|
||||
<ShieldCheckIcon size={14} />
|
||||
Access Control
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
|
||||
<Settings size={14} />
|
||||
Advanced Settings
|
||||
@@ -444,6 +485,7 @@ export default function ReverseProxyModal({
|
||||
baseDomain={baseDomain}
|
||||
onBaseDomainChange={setBaseDomain}
|
||||
domainAlreadyExists={domainAlreadyExists}
|
||||
subdomainRequired={selectedDomain?.require_subdomain === true}
|
||||
clusterOffline={
|
||||
reverseProxy?.proxy_cluster && !isClusterConnected
|
||||
? { clusterName: reverseProxy.proxy_cluster }
|
||||
@@ -527,10 +569,32 @@ export default function ReverseProxyModal({
|
||||
enabled={pinEnabled}
|
||||
onClick={() => setPinModalOpen(true)}
|
||||
/>
|
||||
<SettingCard.Item
|
||||
label={
|
||||
<>
|
||||
<FileCode2Icon size={15} />
|
||||
HTTP Headers
|
||||
</>
|
||||
}
|
||||
description="Require specific HTTP headers to access this service."
|
||||
enabled={headerAuthsEnabled}
|
||||
onClick={() => setHeaderModalOpen(true)}
|
||||
/>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"access-control"} className={"pb-8"}>
|
||||
<div className={"px-8 flex-col flex gap-4"}>
|
||||
<ReverseProxyAccessControlRules
|
||||
value={accessRestrictions}
|
||||
onChange={setAccessRestrictions}
|
||||
onValidationChange={setAccessControlHasErrors}
|
||||
supportsCrowdSec={selectedDomain?.supports_crowdsec}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"settings"} className={"pb-8"}>
|
||||
<div className={"px-8 flex-col flex gap-6"}>
|
||||
{(serviceMode === ServiceMode.TCP ||
|
||||
@@ -627,6 +691,10 @@ export default function ReverseProxyModal({
|
||||
href: REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
|
||||
label: "Authentication",
|
||||
},
|
||||
"access-control": {
|
||||
href: REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
|
||||
label: "Access Control",
|
||||
},
|
||||
settings: {
|
||||
href: REVERSE_PROXY_SETTINGS_DOCS_LINK,
|
||||
label: "Settings",
|
||||
@@ -653,7 +721,9 @@ export default function ReverseProxyModal({
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab(isL4Mode ? "settings" : "auth")}
|
||||
onClick={() =>
|
||||
setTab(isL4Mode ? "access-control" : "auth")
|
||||
}
|
||||
disabled={!canContinueToSettings}
|
||||
>
|
||||
Continue
|
||||
@@ -669,9 +739,27 @@ export default function ReverseProxyModal({
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("access-control")}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "access-control" && (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("settings")}
|
||||
disabled={accessControlHasErrors}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
@@ -682,7 +770,7 @@ export default function ReverseProxyModal({
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
|
||||
onClick={() => setTab("access-control")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
@@ -691,7 +779,8 @@ export default function ReverseProxyModal({
|
||||
disabled={
|
||||
!canContinueToSettings ||
|
||||
!permission?.services?.create ||
|
||||
!!timeoutError
|
||||
!!timeoutError ||
|
||||
accessControlHasErrors
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
@@ -711,7 +800,8 @@ export default function ReverseProxyModal({
|
||||
disabled={
|
||||
!canContinueToSettings ||
|
||||
!permission?.services?.update ||
|
||||
!!timeoutError
|
||||
!!timeoutError ||
|
||||
accessControlHasErrors
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
@@ -806,6 +896,25 @@ export default function ReverseProxyModal({
|
||||
}, 200);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthHeaderModal
|
||||
open={headerModalOpen}
|
||||
onOpenChange={setHeaderModalOpen}
|
||||
key={headerModalOpen ? "h1" : "h0"}
|
||||
currentHeaders={headerAuths}
|
||||
onSave={(headers) => {
|
||||
setTimeout(() => {
|
||||
setHeaderAuths(headers);
|
||||
setHeaderAuthsEnabled(true);
|
||||
}, 200);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setTimeout(() => {
|
||||
setHeaderAuths([]);
|
||||
setHeaderAuthsEnabled(false);
|
||||
}, 200);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
454
src/modules/reverse-proxy/auth/AuthHeaderModal.tsx
Normal file
454
src/modules/reverse-proxy/auth/AuthHeaderModal.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Input } from "@components/Input";
|
||||
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import {
|
||||
BracesIcon,
|
||||
CircleUserIcon,
|
||||
FileCode2Icon,
|
||||
KeyRoundIcon,
|
||||
MinusCircleIcon,
|
||||
PlusIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useReducer, useRef } from "react";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
import type { HeaderAuthConfig } from "@/interfaces/ReverseProxy";
|
||||
|
||||
type HeaderType = "basic" | "bearer" | "custom";
|
||||
|
||||
interface HeaderAuthItem {
|
||||
id: string;
|
||||
type: HeaderType;
|
||||
header: string;
|
||||
value: string;
|
||||
username: string;
|
||||
password: string;
|
||||
existingSecret: boolean;
|
||||
}
|
||||
|
||||
const HEADER_TYPE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
value: "basic" satisfies HeaderType,
|
||||
label: "Basic Auth",
|
||||
icon: () => <CircleUserIcon size={14} />,
|
||||
},
|
||||
{
|
||||
value: "bearer" satisfies HeaderType,
|
||||
label: "Bearer Token",
|
||||
icon: () => <KeyRoundIcon size={14} />,
|
||||
},
|
||||
{
|
||||
value: "custom" satisfies HeaderType,
|
||||
label: "Custom Header",
|
||||
icon: () => <BracesIcon size={14} />,
|
||||
},
|
||||
];
|
||||
|
||||
const MASKED_VALUE = "••••••••";
|
||||
|
||||
const INPUT_PROPS = {
|
||||
autoComplete: "off",
|
||||
"data-1p-ignore": true,
|
||||
"data-lpignore": "true",
|
||||
"data-form-type": "other",
|
||||
} as const;
|
||||
|
||||
function createHeaderEntry(
|
||||
overrides?: Partial<HeaderAuthItem>,
|
||||
): HeaderAuthItem {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "basic",
|
||||
header: "Authorization",
|
||||
value: "",
|
||||
username: "",
|
||||
password: "",
|
||||
existingSecret: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function toBase64(str: string): string {
|
||||
return btoa(
|
||||
new TextEncoder()
|
||||
.encode(str)
|
||||
.reduce((acc, byte) => acc + String.fromCharCode(byte), ""),
|
||||
);
|
||||
}
|
||||
|
||||
function fromBase64(b64: string): string {
|
||||
return new TextDecoder().decode(
|
||||
Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)),
|
||||
);
|
||||
}
|
||||
|
||||
function headerEntryToConfig(entry: HeaderAuthItem): HeaderAuthConfig {
|
||||
if (entry.existingSecret) {
|
||||
const value = entry.value === MASKED_VALUE ? "" : entry.value;
|
||||
return { enabled: true, header: entry.header, value };
|
||||
}
|
||||
switch (entry.type) {
|
||||
case "basic": {
|
||||
const encoded = toBase64(`${entry.username}:${entry.password}`);
|
||||
return {
|
||||
enabled: true,
|
||||
header: "Authorization",
|
||||
value: `Basic ${encoded}`,
|
||||
};
|
||||
}
|
||||
case "bearer":
|
||||
return {
|
||||
enabled: true,
|
||||
header: "Authorization",
|
||||
value: `Bearer ${entry.value}`,
|
||||
};
|
||||
case "custom":
|
||||
return { enabled: true, header: entry.header, value: entry.value };
|
||||
}
|
||||
}
|
||||
|
||||
function configToHeaderEntry(config: HeaderAuthConfig): HeaderAuthItem {
|
||||
const isExisting = !config.value;
|
||||
|
||||
if (config.header === "Authorization" && config.value?.startsWith("Basic ")) {
|
||||
try {
|
||||
const decoded = fromBase64(config.value.slice(6));
|
||||
const sep = decoded.indexOf(":");
|
||||
if (sep >= 0) {
|
||||
return createHeaderEntry({
|
||||
type: "basic",
|
||||
username: decoded.slice(0, sep),
|
||||
password: decoded.slice(sep + 1),
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (
|
||||
config.header === "Authorization" &&
|
||||
config.value?.startsWith("Bearer ")
|
||||
) {
|
||||
return createHeaderEntry({ type: "bearer", value: config.value.slice(7) });
|
||||
}
|
||||
|
||||
return createHeaderEntry({
|
||||
type: isExisting && config.header === "Authorization" ? "basic" : "custom",
|
||||
header: config.header,
|
||||
value: isExisting ? MASKED_VALUE : config.value ?? "",
|
||||
existingSecret: isExisting,
|
||||
});
|
||||
}
|
||||
|
||||
function isHeaderValid(entry: HeaderAuthItem): boolean {
|
||||
if (entry.existingSecret) return true;
|
||||
switch (entry.type) {
|
||||
case "basic":
|
||||
return entry.username.trim().length > 0 && entry.password.length > 0;
|
||||
case "bearer":
|
||||
return entry.value.trim().length > 0;
|
||||
case "custom":
|
||||
return entry.header.trim().length > 0 && entry.value.trim().length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
type HeaderAction =
|
||||
| { type: "add" }
|
||||
| { type: "remove"; index: number }
|
||||
| { type: "update"; index: number; updates: Partial<HeaderAuthItem> };
|
||||
|
||||
function headersReducer(
|
||||
state: HeaderAuthItem[],
|
||||
action: HeaderAction,
|
||||
): HeaderAuthItem[] {
|
||||
switch (action.type) {
|
||||
case "add":
|
||||
return [...state, createHeaderEntry()];
|
||||
case "remove":
|
||||
return state.length === 1
|
||||
? [createHeaderEntry()]
|
||||
: state.filter((_, i) => i !== action.index);
|
||||
case "update":
|
||||
return state.map((e, i) =>
|
||||
i === action.index ? { ...e, ...action.updates } : e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function initHeaders(headers: HeaderAuthConfig[]): HeaderAuthItem[] {
|
||||
return headers.length > 0
|
||||
? headers.map(configToHeaderEntry)
|
||||
: [createHeaderEntry()];
|
||||
}
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentHeaders: HeaderAuthConfig[];
|
||||
onSave: (headers: HeaderAuthConfig[]) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
export default function AuthHeaderModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentHeaders,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: Readonly<Props>) {
|
||||
const [items, dispatch] = useReducer(
|
||||
headersReducer,
|
||||
currentHeaders,
|
||||
initHeaders,
|
||||
);
|
||||
const isEditing = currentHeaders.length > 0;
|
||||
const canSave = useMemo(() => items.every(isHeaderValid), [items]);
|
||||
const { hasChanges } = useHasChanges(items);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSave) return;
|
||||
onOpenChange(false);
|
||||
onSave(items.map(headerEntryToConfig));
|
||||
};
|
||||
|
||||
const handleRemoveAll = () => {
|
||||
onOpenChange(false);
|
||||
onRemove();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
maxWidthClass="max-w-xl"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
const container = e.currentTarget as HTMLElement | null;
|
||||
container
|
||||
?.querySelector<HTMLInputElement>("input:not([type=hidden])")
|
||||
?.focus();
|
||||
}}
|
||||
>
|
||||
<ModalHeader
|
||||
title="HTTP Headers"
|
||||
description="Require specific HTTP headers to access this service."
|
||||
/>
|
||||
|
||||
<div className="px-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map((item, index) => (
|
||||
<HeaderItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onChange={(updates) =>
|
||||
dispatch({ type: "update", index, updates })
|
||||
}
|
||||
onRemove={() => dispatch({ type: "remove", index })}
|
||||
showRemove={items.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="dotted"
|
||||
className="w-full mt-4"
|
||||
size="sm"
|
||||
onClick={() => dispatch({ type: "add" })}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Header
|
||||
</Button>
|
||||
|
||||
{items.length > 1 && (
|
||||
<Callout className="mt-4" variant="info">
|
||||
Any request matching one of these headers will grant access.
|
||||
<br />
|
||||
Matched headers are stripped before reaching your backend.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 w-full justify-between mt-6">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button variant="danger-text" onClick={handleRemoveAll}>
|
||||
Remove All
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || !hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div />
|
||||
<div className="flex gap-3">
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
Add Headers
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderItemRowProps = {
|
||||
item: HeaderAuthItem;
|
||||
index: number;
|
||||
onChange: (updates: Partial<HeaderAuthItem>) => void;
|
||||
onRemove: () => void;
|
||||
showRemove: boolean;
|
||||
};
|
||||
|
||||
function HeaderItemRow({
|
||||
item,
|
||||
index,
|
||||
onChange,
|
||||
onRemove,
|
||||
showRemove,
|
||||
}: Readonly<HeaderItemRowProps>) {
|
||||
const isMaskedRef = useRef(item.existingSecret);
|
||||
|
||||
const handleHeaderTypeChange = (value: string) => {
|
||||
const type = value as HeaderType;
|
||||
onChange({
|
||||
type,
|
||||
header: type === "custom" ? "" : "Authorization",
|
||||
value: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-nb-gray-900 bg-nb-gray-920/30 overflow-hidden">
|
||||
<div className="flex flex-col gap-2 px-4 pt-2 pb-4 bg-nb-gray-920/30">
|
||||
<div className="flex items-center justify-between h-6 mt-0.5">
|
||||
<span className="text-xs font-normal text-nb-gray-200 flex items-center gap-2">
|
||||
<FileCode2Icon size={14} />
|
||||
{item.existingSecret
|
||||
? `Header ${index + 1} - ${item.header}`
|
||||
: `Header ${index + 1}`}
|
||||
</span>
|
||||
{showRemove && (
|
||||
<Button variant="danger-text" size="xs" onClick={onRemove}>
|
||||
<MinusCircleIcon size={12} />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{item.existingSecret ? (
|
||||
<div>
|
||||
<Input
|
||||
customPrefix={<span className="min-w-[38px]">Value</span>}
|
||||
type="password"
|
||||
showPasswordToggle={!isMaskedRef.current}
|
||||
value={isMaskedRef.current ? MASKED_VALUE : item.value}
|
||||
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
|
||||
{...INPUT_PROPS}
|
||||
onChange={(e) => {
|
||||
if (isMaskedRef.current) {
|
||||
isMaskedRef.current = false;
|
||||
const nativeEvent = e.nativeEvent as InputEvent;
|
||||
onChange({ value: nativeEvent.data ?? "" });
|
||||
return;
|
||||
}
|
||||
onChange({ value: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SelectDropdown
|
||||
value={item.type}
|
||||
onChange={handleHeaderTypeChange}
|
||||
options={HEADER_TYPE_OPTIONS}
|
||||
/>
|
||||
|
||||
{item.type === "basic" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
customPrefix={<UserIcon size={16} />}
|
||||
placeholder="Username"
|
||||
maxWidthClass="w-full"
|
||||
value={item.username}
|
||||
onChange={(e) => onChange({ username: e.target.value })}
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
<Input
|
||||
customPrefix={<KeyRoundIcon size={16} />}
|
||||
placeholder="Password"
|
||||
maxWidthClass="w-full"
|
||||
value={item.password}
|
||||
onChange={(e) => onChange({ password: e.target.value })}
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === "bearer" && (
|
||||
<Input
|
||||
customPrefix={"Bearer"}
|
||||
placeholder="e.g. eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
maxWidthClass="w-full"
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.type === "custom" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
customPrefix={<span className="min-w-[38px]">Name</span>}
|
||||
placeholder="e.g., X-API-Key"
|
||||
maxWidthClass="w-full"
|
||||
value={item.header}
|
||||
onChange={(e) => onChange({ header: e.target.value })}
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
<Input
|
||||
customPrefix={<span className="min-w-[38px]">Value</span>}
|
||||
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
|
||||
maxWidthClass="w-full"
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ type Props = {
|
||||
baseDomain: string;
|
||||
onBaseDomainChange: (value: string) => void;
|
||||
domainAlreadyExists: boolean;
|
||||
subdomainRequired?: boolean;
|
||||
clusterOffline?: {
|
||||
clusterName: string;
|
||||
};
|
||||
@@ -24,13 +25,16 @@ export default function ReverseProxyDomainInput({
|
||||
baseDomain,
|
||||
onBaseDomainChange,
|
||||
domainAlreadyExists,
|
||||
subdomainRequired = false,
|
||||
clusterOffline,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div>
|
||||
<Label>Domain</Label>
|
||||
<HelpText>
|
||||
Enter a subdomain and select a domain for your service.
|
||||
{subdomainRequired
|
||||
? "Enter a subdomain and select a domain for your service."
|
||||
: "Optionally enter a subdomain, or use the domain directly."}
|
||||
</HelpText>
|
||||
<div className="flex items-start mt-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -47,7 +51,7 @@ export default function ReverseProxyDomainInput({
|
||||
? "This domain is already used by another service."
|
||||
: undefined
|
||||
}
|
||||
placeholder={"myapp"}
|
||||
placeholder={subdomainRequired ? "myapp" : "myapp (optional)"}
|
||||
className="!rounded-r-none !border-r-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,13 @@ function parseDomain(
|
||||
.filter((d) => d.domain)
|
||||
.sort((a, b) => b.domain.length - a.domain.length);
|
||||
for (const d of sorted) {
|
||||
if (fullDomain === d.domain) {
|
||||
return {
|
||||
subdomain: "",
|
||||
baseDomain: d.domain,
|
||||
isCustom: d.type === ReverseProxyDomainType.CUSTOM,
|
||||
};
|
||||
}
|
||||
if (fullDomain.endsWith(`.${d.domain}`)) {
|
||||
return {
|
||||
subdomain: fullDomain.slice(0, -(d.domain.length + 1)),
|
||||
@@ -103,7 +110,11 @@ export function useReverseProxyDomain({
|
||||
return customDomain?.domain || freeDomain?.domain || "";
|
||||
});
|
||||
|
||||
const fullDomain = baseDomain ? `${subdomain}.${baseDomain}` : subdomain;
|
||||
const fullDomain = baseDomain
|
||||
? subdomain
|
||||
? `${subdomain}.${baseDomain}`
|
||||
: baseDomain
|
||||
: subdomain;
|
||||
|
||||
const domainAlreadyExists = useMemo(() => {
|
||||
if (!reverseProxies || !fullDomain) return false;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { Binary, Mail, RectangleEllipsis, Users } from "lucide-react";
|
||||
import {
|
||||
Binary,
|
||||
FileCode2Icon,
|
||||
Flag,
|
||||
GlobeOff,
|
||||
Mail,
|
||||
Network,
|
||||
RectangleEllipsis,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||
|
||||
@@ -33,6 +44,11 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
|
||||
icon: <Binary size={12} />,
|
||||
label: "PIN Code",
|
||||
};
|
||||
case "header":
|
||||
return {
|
||||
icon: <FileCode2Icon size={12} />,
|
||||
label: "HTTP Headers",
|
||||
};
|
||||
case "link":
|
||||
case "magic_link":
|
||||
case "magic-link":
|
||||
@@ -40,6 +56,41 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
|
||||
icon: <Mail size={12} />,
|
||||
label: "Magic Link",
|
||||
};
|
||||
case "ip_restricted":
|
||||
return {
|
||||
icon: <Network size={12} />,
|
||||
label: "IP Restricted",
|
||||
};
|
||||
case "country_restricted":
|
||||
return {
|
||||
icon: <Flag size={12} />,
|
||||
label: "Country Restricted",
|
||||
};
|
||||
case "geo_unavailable":
|
||||
return {
|
||||
icon: <GlobeOff size={12} />,
|
||||
label: "Geo Unavailable",
|
||||
};
|
||||
case "crowdsec_ban":
|
||||
return {
|
||||
icon: <ShieldAlert size={12} />,
|
||||
label: "CrowdSec Ban",
|
||||
};
|
||||
case "crowdsec_captcha":
|
||||
return {
|
||||
icon: <ShieldAlert size={12} />,
|
||||
label: "CrowdSec Captcha",
|
||||
};
|
||||
case "crowdsec_throttle":
|
||||
return {
|
||||
icon: <ShieldAlert size={12} />,
|
||||
label: "CrowdSec Throttle",
|
||||
};
|
||||
case "crowdsec_unavailable":
|
||||
return {
|
||||
icon: <ShieldOff size={12} />,
|
||||
label: "CrowdSec Unavailable",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: null,
|
||||
|
||||
@@ -19,8 +19,17 @@ export const ReverseProxyEventsLocationIpCell = ({ event }: Props) => {
|
||||
const { getRegionText, isLoading } = useCountries();
|
||||
|
||||
const region = useMemo(() => {
|
||||
return getRegionText(event.country_code || "", event.city_name || "");
|
||||
}, [getRegionText, event.country_code, event.city_name]);
|
||||
return getRegionText(
|
||||
event.country_code || "",
|
||||
event.city_name || "",
|
||||
event.subdivision_code,
|
||||
);
|
||||
}, [
|
||||
getRegionText,
|
||||
event.country_code,
|
||||
event.city_name,
|
||||
event.subdivision_code,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
|
||||
@@ -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 || "-"}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@components/HoverCard";
|
||||
import {
|
||||
FlagIcon,
|
||||
LucideIcon,
|
||||
NetworkIcon,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useCountries } from "@/contexts/CountryProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
|
||||
type RuleEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
value: string;
|
||||
blocked?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
reverseProxy: ReverseProxy;
|
||||
};
|
||||
|
||||
export default function ReverseProxyAccessControlCell({
|
||||
reverseProxy,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const { openModal, domains } = useReverseProxies();
|
||||
const { countries } = useCountries();
|
||||
|
||||
const canConfigure = !!permission?.services?.update;
|
||||
const restrictions = reverseProxy.access_restrictions;
|
||||
|
||||
const supportsCrowdSec = domains?.find(
|
||||
(d) => d.domain === reverseProxy.proxy_cluster,
|
||||
)?.supports_crowdsec;
|
||||
|
||||
const hasCrowdSec =
|
||||
supportsCrowdSec &&
|
||||
restrictions?.crowdsec_mode != null &&
|
||||
restrictions.crowdsec_mode !== CrowdSecMode.OFF;
|
||||
|
||||
const ruleCount =
|
||||
(restrictions?.allowed_cidrs?.length ?? 0) +
|
||||
(restrictions?.blocked_cidrs?.length ?? 0) +
|
||||
(restrictions?.allowed_countries?.length ?? 0) +
|
||||
(restrictions?.blocked_countries?.length ?? 0) +
|
||||
(hasCrowdSec ? 1 : 0);
|
||||
|
||||
const rulesBadge =
|
||||
ruleCount > 0 ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
disabled={!canConfigure}
|
||||
className={
|
||||
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
|
||||
}
|
||||
>
|
||||
<ShieldCheck size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>
|
||||
{ruleCount} {ruleCount === 1 ? "Rule" : "Rules"}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
const ruleGroups = useMemo(() => {
|
||||
const getCountryName = (code: string) => {
|
||||
const country = countries?.find((c) => c.country_code === code);
|
||||
return country?.country_name ?? code;
|
||||
};
|
||||
|
||||
const entries: RuleEntry[] = [];
|
||||
|
||||
if (restrictions?.allowed_countries?.length) {
|
||||
entries.push({
|
||||
key: "allowed-countries",
|
||||
label: "Allowed Countries",
|
||||
Icon: FlagIcon,
|
||||
value: restrictions.allowed_countries.map(getCountryName).join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (restrictions?.blocked_countries?.length) {
|
||||
entries.push({
|
||||
key: "blocked-countries",
|
||||
label: "Blocked Countries",
|
||||
Icon: FlagIcon,
|
||||
value: restrictions.blocked_countries.map(getCountryName).join(", "),
|
||||
blocked: true,
|
||||
});
|
||||
}
|
||||
|
||||
const isHostCidr = (c: string) =>
|
||||
c.includes(":") ? c.endsWith("/128") : c.endsWith("/32");
|
||||
const allowedIps =
|
||||
restrictions?.allowed_cidrs?.filter(isHostCidr) ?? [];
|
||||
const allowedCidrs =
|
||||
restrictions?.allowed_cidrs?.filter((c) => !isHostCidr(c)) ?? [];
|
||||
const blockedIps =
|
||||
restrictions?.blocked_cidrs?.filter(isHostCidr) ?? [];
|
||||
const blockedCidrs =
|
||||
restrictions?.blocked_cidrs?.filter((c) => !isHostCidr(c)) ?? [];
|
||||
|
||||
if (allowedIps.length) {
|
||||
entries.push({
|
||||
key: "allowed-ips",
|
||||
label: allowedIps.length === 1 ? "Allowed IP" : "Allowed IPs",
|
||||
Icon: WorkflowIcon,
|
||||
value: allowedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (allowedCidrs.length) {
|
||||
entries.push({
|
||||
key: "allowed-cidrs",
|
||||
label: allowedCidrs.length === 1 ? "Allowed CIDR" : "Allowed CIDRs",
|
||||
Icon: NetworkIcon,
|
||||
value: allowedCidrs.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (blockedIps.length) {
|
||||
entries.push({
|
||||
key: "blocked-ips",
|
||||
label: blockedIps.length === 1 ? "Blocked IP" : "Blocked IPs",
|
||||
Icon: WorkflowIcon,
|
||||
value: blockedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
|
||||
blocked: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (blockedCidrs.length) {
|
||||
entries.push({
|
||||
key: "blocked-cidrs",
|
||||
label: blockedCidrs.length === 1 ? "Blocked CIDR" : "Blocked CIDRs",
|
||||
Icon: NetworkIcon,
|
||||
value: blockedCidrs.join(", "),
|
||||
blocked: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCrowdSec) {
|
||||
entries.push({
|
||||
key: "crowdsec",
|
||||
label: "CrowdSec",
|
||||
Icon: ShieldAlert,
|
||||
value:
|
||||
restrictions?.crowdsec_mode === CrowdSecMode.ENFORCE
|
||||
? "Enforce"
|
||||
: "Observe",
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [restrictions, countries, hasCrowdSec]);
|
||||
|
||||
const showRulesHover = ruleGroups.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={"flex"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (permission?.services?.update) {
|
||||
openModal({ proxy: reverseProxy, initialTab: "access-control" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
{rulesBadge ? (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>{rulesBadge}</HoverCardTrigger>
|
||||
{showRulesHover && (
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
sideOffset={14}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-xs"}>
|
||||
{ruleGroups.map(({ key, label, Icon, value, blocked }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={
|
||||
"flex justify-between gap-12 py-2 px-4 border-b border-nb-gray-920 last:border-b-0"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-start gap-2 font-medium whitespace-nowrap text-nb-gray-100 pt-0.5"
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={
|
||||
blocked ? "text-red-500" : "text-green-500"
|
||||
}
|
||||
/>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={"max-w-[200px] text-nb-gray-300 text-right"}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</HoverCard>
|
||||
) : (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
disabled={!canConfigure}
|
||||
className={
|
||||
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
|
||||
}
|
||||
>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>No Rules</span>
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"!rounded-l-none !px-3 !h-[34px]"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openModal({ proxy: reverseProxy, initialTab: "access-control" });
|
||||
}}
|
||||
disabled={!permission?.services?.update}
|
||||
aria-label="Configure access control"
|
||||
>
|
||||
<Settings size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -11,12 +11,13 @@ import { UserCountStack } from "@components/ui/MultipleGroups";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
Binary,
|
||||
FileCode2Icon,
|
||||
HelpCircle,
|
||||
LockKeyhole,
|
||||
LockOpenIcon,
|
||||
LucideIcon,
|
||||
RectangleEllipsis,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -48,6 +49,12 @@ const AUTH_METHODS: {
|
||||
},
|
||||
];
|
||||
|
||||
const HEADER_AUTH_METHOD = {
|
||||
label: "HTTP Headers",
|
||||
hoverLabel: "HTTP Headers",
|
||||
Icon: FileCode2Icon,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
reverseProxy: ReverseProxy;
|
||||
};
|
||||
@@ -59,7 +66,6 @@ export default function ReverseProxyAuthCell({
|
||||
const { openModal } = useReverseProxies();
|
||||
const { groups } = useGroups();
|
||||
|
||||
// L4 services don't support auth
|
||||
if (isL4Mode(reverseProxy.mode)) {
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
@@ -83,6 +89,8 @@ export default function ReverseProxyAuthCell({
|
||||
const auth = reverseProxy.auth;
|
||||
|
||||
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
|
||||
const hasHeaderAuths = (auth?.header_auths ?? []).some((h) => h.enabled);
|
||||
const authCount = enabled.length + (hasHeaderAuths ? 1 : 0);
|
||||
|
||||
const ssoGroups = auth?.bearer_auth?.enabled
|
||||
? (auth.bearer_auth.distribution_groups ?? [])
|
||||
@@ -90,103 +98,149 @@ export default function ReverseProxyAuthCell({
|
||||
.filter((g): g is Group => g != undefined)
|
||||
: [];
|
||||
|
||||
const showHoverContent =
|
||||
enabled.length > 1 || (enabled.length === 1 && auth?.bearer_auth?.enabled);
|
||||
const canConfigure = !!permission?.services?.update;
|
||||
const singleAuth =
|
||||
authCount === 1
|
||||
? enabled.length === 1
|
||||
? enabled[0]
|
||||
: HEADER_AUTH_METHOD
|
||||
: null;
|
||||
const SingleAuthIcon = singleAuth?.Icon ?? null;
|
||||
|
||||
const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null;
|
||||
|
||||
const badgeContent = SingleIcon ? (
|
||||
<>
|
||||
<SingleIcon size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
|
||||
</>
|
||||
) : enabled.length > 1 ? (
|
||||
<>
|
||||
<ShieldCheck size={12} className="text-green-400" />
|
||||
<span className={"font-medium text-xs"}>{enabled.length} Enabled</span>
|
||||
</>
|
||||
const authBadge = SingleAuthIcon ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
disabled={!canConfigure}
|
||||
className={
|
||||
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
|
||||
}
|
||||
>
|
||||
<SingleAuthIcon size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>{singleAuth!.label}</span>
|
||||
</Badge>
|
||||
) : authCount > 1 ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
disabled={!canConfigure}
|
||||
className={
|
||||
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
|
||||
}
|
||||
>
|
||||
<LockKeyhole size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>{authCount} Enabled</span>
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
const showAuthHover =
|
||||
authCount > 1 ||
|
||||
(authCount === 1 && (auth?.bearer_auth?.enabled || hasHeaderAuths));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={"flex gap-3"}
|
||||
className={"flex"}
|
||||
data-auth-cell
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
if (permission?.services?.update) {
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
{badgeContent ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"cursor-pointer"}
|
||||
>
|
||||
{badgeContent}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={"gray"}>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>None</span>
|
||||
</Badge>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
{showHoverContent && (
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
sideOffset={14}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-xs"}>
|
||||
{enabled.map(({ key, hoverLabel, Icon }) => (
|
||||
<ListItem
|
||||
key={key}
|
||||
className={"py-0.5"}
|
||||
icon={<Icon size={14} />}
|
||||
label={hoverLabel}
|
||||
value={
|
||||
<div className={"text-green-500"}>
|
||||
{key === "bearer_auth" && ssoGroups.length === 0
|
||||
? "All Users"
|
||||
: "Enabled"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{key === "bearer_auth" && ssoGroups.length > 0 && (
|
||||
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
|
||||
{ssoGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={"flex gap-2 items-center justify-between"}
|
||||
>
|
||||
<GroupBadge group={group} />
|
||||
<ArrowRightIcon size={14} />
|
||||
<UserCountStack group={group} />
|
||||
<div className={"flex items-center"}>
|
||||
{authBadge ? (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>{authBadge}</HoverCardTrigger>
|
||||
{showAuthHover && (
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
sideOffset={14}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-xs"}>
|
||||
{enabled.map(({ key, hoverLabel, Icon }) => (
|
||||
<ListItem
|
||||
key={key}
|
||||
className={"py-0.5"}
|
||||
icon={<Icon size={14} />}
|
||||
label={hoverLabel}
|
||||
value={
|
||||
<div className={"text-green-500"}>
|
||||
{key === "bearer_auth" && ssoGroups.length === 0
|
||||
? "All Users"
|
||||
: "Enabled"}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{key === "bearer_auth" && ssoGroups.length > 0 && (
|
||||
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
|
||||
{ssoGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={
|
||||
"flex gap-2 items-center justify-between"
|
||||
}
|
||||
>
|
||||
<GroupBadge group={group} />
|
||||
<ArrowRightIcon size={14} />
|
||||
<UserCountStack group={group} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
{hasHeaderAuths && (
|
||||
<ListItem
|
||||
className={"py-0.5"}
|
||||
icon={<FileCode2Icon size={14} />}
|
||||
label={HEADER_AUTH_METHOD.hoverLabel}
|
||||
value={
|
||||
<div className={"text-green-500"}>
|
||||
{
|
||||
(auth?.header_auths ?? []).filter((h) => h.enabled)
|
||||
.length
|
||||
}{" "}
|
||||
Header
|
||||
{(auth?.header_auths ?? []).filter((h) => h.enabled)
|
||||
.length !== 1
|
||||
? "s"
|
||||
: ""}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</HoverCard>
|
||||
) : (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
disabled={!canConfigure}
|
||||
className={
|
||||
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
|
||||
}
|
||||
>
|
||||
<LockOpenIcon size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>No Auth</span>
|
||||
</Badge>
|
||||
)}
|
||||
</HoverCard>
|
||||
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
}}
|
||||
className={"!px-3"}
|
||||
disabled={!permission?.services?.update}
|
||||
>
|
||||
<Settings size={12} />
|
||||
Configure
|
||||
</Button>
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"!rounded-l-none !px-3 !h-[34px]"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
}}
|
||||
disabled={!permission?.services?.update}
|
||||
aria-label="Configure authentication"
|
||||
>
|
||||
<Settings size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import ReverseProxyActionCell from "@/modules/reverse-proxy/table/ReverseProxyActionCell";
|
||||
import ReverseProxyActiveCell from "@/modules/reverse-proxy/table/ReverseProxyActiveCell";
|
||||
import ReverseProxyAccessControlCell from "@/modules/reverse-proxy/table/ReverseProxyAccessControlCell";
|
||||
import ReverseProxyAuthCell from "@/modules/reverse-proxy/table/ReverseProxyAuthCell";
|
||||
import ReverseProxyClusterCell from "@/modules/reverse-proxy/table/ReverseProxyClusterCell";
|
||||
import ReverseProxyNameCell from "@/modules/reverse-proxy/table/ReverseProxyNameCell";
|
||||
@@ -90,6 +91,17 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
||||
},
|
||||
cell: ({ row }) => <ReverseProxyAuthCell reverseProxy={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "access_rules",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader column={column}>Access Control</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyAccessControlCell reverseProxy={row.original} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -461,7 +461,7 @@ export default function ReverseProxyTargetModal({
|
||||
<Label>Request Timeout</Label>
|
||||
<HelpText className={"mb-0"}>
|
||||
Max time to wait for a response as duration string
|
||||
(max 5m). <br /> Leave this field empty for no
|
||||
(e.g. 30s, 2m). <br /> Leave this field empty for no
|
||||
timeout.
|
||||
</HelpText>
|
||||
</div>
|
||||
@@ -487,7 +487,7 @@ export default function ReverseProxyTargetModal({
|
||||
<Label>Session Idle Timeout</Label>
|
||||
<HelpText className={"mb-0"}>
|
||||
How long a UDP session stays alive without traffic
|
||||
(max 10m). <br /> Defaults to 30s when empty.
|
||||
(e.g., 30s, 2m). <br /> Defaults to 30s when empty.
|
||||
</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
|
||||
import ReverseProxyArrowCell from "@/modules/reverse-proxy/table/ReverseProxyArrowCell";
|
||||
import ReverseProxyAccessControlCell from "@/modules/reverse-proxy/table/ReverseProxyAccessControlCell";
|
||||
import ReverseProxyAuthCell from "@/modules/reverse-proxy/table/ReverseProxyAuthCell";
|
||||
import ReverseProxyClusterCell from "@/modules/reverse-proxy/table/ReverseProxyClusterCell";
|
||||
import ReverseProxyDestinationCell from "@/modules/reverse-proxy/table/ReverseProxyDestinationCell";
|
||||
@@ -48,13 +49,19 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
const isEnabled = target.proxy.enabled && target.enabled;
|
||||
|
||||
return (
|
||||
<div className={disabled ? "opacity-40" : ""}>
|
||||
<ReverseProxyNameCell
|
||||
domain={fullUrl}
|
||||
enabled={isEnabled}
|
||||
reverseProxy={row.original.proxy}
|
||||
showChevron={false}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={disabled ? "opacity-40" : ""}
|
||||
data-proxy-id={target.proxy.id}
|
||||
>
|
||||
<ReverseProxyNameCell
|
||||
domain={fullUrl}
|
||||
enabled={isEnabled}
|
||||
reverseProxy={row.original.proxy}
|
||||
showChevron={false}
|
||||
/>
|
||||
</div>
|
||||
<div data-status-cell />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -63,7 +70,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
accessorKey: "arrow",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyArrowCell disabled={!row.original.enabled} />
|
||||
<div data-proxy-id={row.original.proxy.id}>
|
||||
<ReverseProxyArrowCell disabled={row.original.enabled === false} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -71,7 +80,11 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Destination</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyDestinationCell target={row.original} />,
|
||||
cell: ({ row }) => (
|
||||
<div data-proxy-id={row.original.proxy.id}>
|
||||
<ReverseProxyDestinationCell target={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
@@ -79,9 +92,11 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
<DataTableHeader column={column}>Active</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyTargetProvider value={row.original.proxy}>
|
||||
<ReverseProxyTargetActiveCell target={row.original} />
|
||||
</ReverseProxyTargetProvider>
|
||||
<div data-proxy-id={row.original.proxy.id}>
|
||||
<ReverseProxyTargetProvider value={row.original.proxy}>
|
||||
<ReverseProxyTargetActiveCell target={row.original} />
|
||||
</ReverseProxyTargetProvider>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -90,7 +105,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
<DataTableHeader column={column}>Cluster</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyClusterCell reverseProxy={row.original.proxy} />
|
||||
<div data-proxy-id={row.original.proxy.id}>
|
||||
<ReverseProxyClusterCell reverseProxy={row.original.proxy} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -99,7 +116,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
<DataTableHeader column={column}>Resource</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyTargetDevice target={row.original} showDescription />
|
||||
<div data-proxy-id={row.original.proxy.id}>
|
||||
<ReverseProxyTargetDevice target={row.original} showDescription />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -108,7 +127,20 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
<DataTableHeader column={column}>Auth Methods</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyAuthCell reverseProxy={row.original.proxy} />
|
||||
<div data-proxy-id={row.original.proxy.id}>
|
||||
<ReverseProxyAuthCell reverseProxy={row.original.proxy} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "access_control",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Access Control</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div data-proxy-id={row.original.proxy.id}>
|
||||
<ReverseProxyAccessControlCell reverseProxy={row.original.proxy} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,43 +7,20 @@ import {
|
||||
|
||||
// Go time.ParseDuration format: one or more {number}{unit} pairs
|
||||
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
|
||||
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
|
||||
const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m
|
||||
|
||||
function parseDurationMs(duration: string): number {
|
||||
const units: Record<string, number> = {
|
||||
ns: 1e-6,
|
||||
us: 1e-3,
|
||||
µs: 1e-3,
|
||||
ms: 1,
|
||||
s: 1000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
};
|
||||
let total = 0;
|
||||
for (const [, val, , unit] of duration.matchAll(
|
||||
/(\d+(\.\d+)?)(ns|us|µs|ms|s|m|h)/g,
|
||||
)) {
|
||||
total += parseFloat(val) * units[unit];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function validateTimeout(timeout: string): string | undefined {
|
||||
if (!timeout) return undefined;
|
||||
if (!DURATION_RE.test(timeout))
|
||||
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
|
||||
if (parseDurationMs(timeout) > MAX_TIMEOUT_MS)
|
||||
return "Timeout cannot exceed the maximum of 5m.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateSessionIdleTimeout(timeout: string): string | undefined {
|
||||
export function validateSessionIdleTimeout(
|
||||
timeout: string,
|
||||
): string | undefined {
|
||||
if (!timeout) return undefined;
|
||||
if (!DURATION_RE.test(timeout))
|
||||
return 'Invalid duration, use e.g., "30s", "2m", "5m"';
|
||||
if (parseDurationMs(timeout) > MAX_SESSION_IDLE_TIMEOUT_MS)
|
||||
return "Session idle timeout cannot exceed the maximum of 10m.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -49,6 +49,7 @@ export const idpTypeLabels: Record<SSOIdentityProviderType, string> = {
|
||||
microsoft: "Microsoft",
|
||||
authentik: "Authentik",
|
||||
keycloak: "Keycloak",
|
||||
adfs: "Microsoft AD FS",
|
||||
};
|
||||
|
||||
type ActionCellProps = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
87
src/utils/ip.test.ts
Normal 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
35
src/utils/ip.ts
Normal 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;
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -94,3 +94,4 @@ export const isNetbirdSSHProtocolSupported = (version: string) => {
|
||||
if (version == "development") return true;
|
||||
return compareVersions(version, "0.61.0");
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user