- {label}
+ {label}
diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx
index c8248352..3018000b 100644
--- a/src/components/PeerGroupSelector.tsx
+++ b/src/components/PeerGroupSelector.tsx
@@ -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 = ({
diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx
index df7101b3..e259de7c 100644
--- a/src/components/PeerSelector.tsx
+++ b/src/components/PeerSelector.tsx
@@ -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]"
}
>
-
{value.ip}
@@ -238,7 +238,6 @@ export function PeerSelector({
!isSupported && "opacity-50",
)}
>
-
{option.ip}
diff --git a/src/contexts/PeerProvider.tsx b/src/contexts/PeerProvider.tsx
index 7521d22c..13b86d05 100644
--- a/src/contexts/PeerProvider.tsx
+++ b/src/contexts/PeerProvider.tsx
@@ -30,6 +30,7 @@ const PeerContext = React.createContext(
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
+ ipv6?: string;
}) => Promise;
toggleSSH: (newState: boolean) => Promise;
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}`,
);
diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts
index c87c55e5..5e4b56c9 100644
--- a/src/interfaces/Account.ts
+++ b/src/interfaces/Account.ts
@@ -28,6 +28,8 @@ export interface Account {
auto_update_version: string;
auto_update_always: boolean;
local_auth_disabled?: boolean;
+ ipv6_enabled_groups?: string[];
+ network_range_v6?: string;
};
onboarding?: AccountOnboarding;
}
diff --git a/src/interfaces/Peer.ts b/src/interfaces/Peer.ts
index bacad514..537d106e 100644
--- a/src/interfaces/Peer.ts
+++ b/src/interfaces/Peer.ts
@@ -5,6 +5,7 @@ export interface Peer {
id?: string;
name: string;
ip: string;
+ ipv6?: string;
connected: boolean;
created_at?: Date;
last_seen: Date;
diff --git a/src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx b/src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx
index ab5865b5..a5e316e2 100644
--- a/src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx
+++ b/src/modules/networks/routing-peers/NetworkRoutingPeersTabContent.tsx
@@ -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]);
diff --git a/src/modules/peer/PeerEditIPModal.tsx b/src/modules/peer/PeerEditIPModal.tsx
new file mode 100644
index 00000000..6495bb5b
--- /dev/null
+++ b/src/modules/peer/PeerEditIPModal.tsx
@@ -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) {
+ 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 (
+
+
+
+
+
+ );
+}
diff --git a/src/modules/peers/PeerAddressCell.tsx b/src/modules/peers/PeerAddressCell.tsx
index 2de803ce..7240fc9d 100644
--- a/src/modules/peers/PeerAddressCell.tsx
+++ b/src/modules/peers/PeerAddressCell.tsx
@@ -11,6 +11,7 @@ import { PeerAddressTooltipContent } from "@/modules/peers/PeerAddressTooltipCon
type Props = {
peer: Peer;
};
+
export default function PeerAddressCell({ peer }: Props) {
return (
{
}
/>
+ {peer.ipv6 && (
+ }
+ label={"NetBird IPv6"}
+ value={
+
+ {peer.ipv6}
+
+ }
+ />
+ )}
}
label={"Public IP"}
diff --git a/src/modules/peers/PeersTable.tsx b/src/modules/peers/PeersTable.tsx
index 3fda0740..3b5d16c6 100644
--- a/src/modules/peers/PeersTable.tsx
+++ b/src/modules/peers/PeersTable.tsx
@@ -204,6 +204,10 @@ const PeersTableColumns: ColumnDef[] = [
),
},
+ {
+ id: "ipv6",
+ accessorFn: (row) => row.ipv6,
+ },
];
type Props = {
@@ -321,6 +325,7 @@ export default function PeersTable({
connect: permission.peers.update,
groups: permission.groups.read,
os: false,
+ ipv6: false,
}}
isLoading={isLoading}
getStartedCard={ }
diff --git a/src/modules/remote-access/ssh/useSSH.ts b/src/modules/remote-access/ssh/useSSH.ts
index 121d68ad..95f57c7e 100644
--- a/src/modules/remote-access/ssh/useSSH.ts
+++ b/src/modules/remote-access/ssh/useSSH.ts
@@ -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 = () => {
diff --git a/src/modules/remote-access/ssh/useSSHQueryParams.ts b/src/modules/remote-access/ssh/useSSHQueryParams.ts
index 938f95e8..ea61db31 100644
--- a/src/modules/remote-access/ssh/useSSHQueryParams.ts
+++ b/src/modules/remote-access/ssh/useSSHQueryParams.ts
@@ -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
diff --git a/src/modules/remote-access/useNetBirdClient.ts b/src/modules/remote-access/useNetBirdClient.ts
index 6838fd88..28bd8946 100644
--- a/src/modules/remote-access/useNetBirdClient.ts
+++ b/src/modules/remote-access/useNetBirdClient.ts
@@ -224,6 +224,7 @@ export const useNetBirdClient = () => {
port: number,
username: string,
jwtToken?: string,
+ ipVersion?: string,
): Promise => {
if (!netBirdClient.current?.createSSHConnection) {
throw new Error("Go client not ready");
@@ -233,6 +234,7 @@ export const useNetBirdClient = () => {
port,
username,
jwtToken,
+ ipVersion,
);
},
[],
diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx
index 17e4d4c6..88533d05 100644
--- a/src/modules/settings/NetworkSettingsTab.tsx
+++ b/src/modules/settings/NetworkSettingsTab.tsx
@@ -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) {
+ const { isLoading: isGroupsLoading } = useGroups();
+
+ return isGroupsLoading ? (
+
+ ) : (
+
+ );
+}
+
+function NetworkSettingsTabContent({ account }: Readonly) {
const { permission } = usePermissions();
const { mutate } = useSWRConfig();
@@ -38,6 +52,17 @@ export default function NetworkSettingsTab({ account }: Readonly) {
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) {
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) {
})
.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) {
}
}, [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 (
@@ -150,7 +209,8 @@ export default function NetworkSettingsTab({ account }: Readonly
) {
!hasChanges ||
!permission.settings.update ||
!!domainError ||
- !!networkRangeError
+ !!networkRangeError ||
+ !!networkRangeV6Error
}
onClick={saveChanges}
>
@@ -216,6 +276,51 @@ export default function NetworkSettingsTab({ account }: Readonly) {
+
+
+
+ IPv6 Network Range
+
+ Specify a custom IPv6 range for your network in CIDR format.
+ All peer IPv6 addresses will be re-allocated when changed.
+
+
+
+ setNetworkRangeV6(e.target.value)}
+ />
+
+
+
+
+
+
IPv6 Enabled Groups
+
+ 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.
+
+
+
+
+
+
{
if (version == "development") return true;
return compareVersions(version, "0.61.0");
};
+