From 9be1fa7b303a346c4b9948fd41d67a996fe93e53 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 22 Mar 2026 06:59:25 +0100 Subject: [PATCH 1/4] Add IPv6 overlay settings, peer display, and per-group control - IPv6 network range input with /48-/112 CIDR validation - Per-group IPv6 enabled selector (replaces simple toggle) - Peer IPv6 display: table cell, tooltip, detail page, selectors, search - Gray out IPv6 for peers whose client does not support it yet - DeviceCard and routing peers show IPv6 when available --- src/app/(dashboard)/peer/page.tsx | 128 ++++++++++++++++++ src/components/DeviceCard.tsx | 9 +- src/components/PeerGroupSelector.tsx | 4 +- src/components/PeerSelector.tsx | 5 +- src/contexts/PeerProvider.tsx | 2 + src/interfaces/Account.ts | 2 + src/interfaces/Peer.ts | 1 + .../NetworkRoutingPeersTabContent.tsx | 2 +- src/modules/peers/PeerAddressCell.tsx | 1 + .../peers/PeerAddressTooltipContent.tsx | 15 ++ src/modules/settings/NetworkSettingsTab.tsx | 88 +++++++++++- src/utils/version.ts | 1 + 12 files changed, 250 insertions(+), 8 deletions(-) diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index a5cd5504..636f4c9f 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -3,6 +3,7 @@ import Breadcrumbs from "@components/Breadcrumbs"; import Button from "@components/Button"; import { Callout } from "@components/Callout"; +import cidr from "ip-cidr"; import Card from "@components/Card"; import HelpText from "@components/HelpText"; import { Input } from "@components/Input"; @@ -469,6 +470,7 @@ 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(() => { @@ -494,6 +496,23 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { key={showEditIPModal ? 1 : 0} /> + + { + notify({ + title: peer.name, + description: "Peer IPv6 was successfully updated", + promise: update({ ipv6: newIPv6 }).then(() => { + mutate("/peers/" + peer.id); + setShowEditIPv6Modal(false); + }), + loadingMessage: "Updating peer IPv6...", + }); + }} + peer={peer} + key={showEditIPv6Modal ? 1 : 0} + /> + ) { } /> + {peer.ipv6 && ( + + + NetBird IPv6 Address + + } + valueToCopy={peer.ipv6} + value={ +
+ {peer.ipv6} + {permission.peers.update && ( + + )} +
+ } + /> + )} + ) { ); } + +interface EditIPv6ModalProps { + onSuccess: (ipv6: string) => void; + peer: Peer; +} + +function isValidIPv6(address: string): boolean { + return cidr.isValidAddress(address) && address.includes(":"); +} + +function EditIPv6Modal({ onSuccess, peer }: Readonly) { + const [ipv6, setIPv6] = useState(peer.ipv6 || ""); + const [error, setError] = useState(""); + + const isDisabled = useMemo(() => { + if (ipv6 === peer.ipv6) return true; + const trimmed = trim(ipv6); + return trimmed.length === 0 || !isValidIPv6(trimmed); + }, [ipv6, peer.ipv6]); + + React.useEffect(() => { + switch (true) { + case ipv6 === peer.ipv6: + setError(""); + break; + case !isValidIPv6(trim(ipv6)): + setError("Please enter a valid IPv6 address, e.g., fd00:1234::1"); + break; + default: + setError(""); + break; + } + }, [ipv6, peer.ipv6]); + + return ( + +
+ + +
+
+ setIPv6(e.target.value)} + error={error} + /> +
+ + Changes take effect when the peer reconnects. +
+ + +
+ + + + + +
+
+ +
+ ); +} diff --git a/src/components/DeviceCard.tsx b/src/components/DeviceCard.tsx index 9f0b74c6..a84de586 100644 --- a/src/components/DeviceCard.tsx +++ b/src/components/DeviceCard.tsx @@ -28,8 +28,13 @@ export const DeviceCard = ({ const descriptionText = useMemo(() => { return description !== undefined ? description - : address || device?.ip || resource?.address; - }, [description, address, device]); + : address || + (device?.ip + ? device.ipv6 + ? `${device.ip}, ${device.ipv6}` + : device.ip + : resource?.address); + }, [description, address, device, resource]); return (
{ 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 = ({ @@ -1059,6 +1060,7 @@ const PeersList = ({ } > {res.ip} + {res.ipv6 && `, ${res.ipv6}`}
diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx index df7101b3..f3c9c8ff 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({ @@ -126,6 +127,7 @@ export function PeerSelector({ > {value.ip} + {value.ipv6 && `, ${value.ipv6}`} ) : ( @@ -240,6 +242,7 @@ export function PeerSelector({ > {option.ip} + {option.ipv6 && `, ${option.ipv6}`} ); diff --git a/src/contexts/PeerProvider.tsx b/src/contexts/PeerProvider.tsx index 7521d22c..d0a45f0b 100644 --- a/src/contexts/PeerProvider.tsx +++ b/src/contexts/PeerProvider.tsx @@ -80,6 +80,7 @@ export default function PeerProvider({ inactivityExpiration?: boolean; approval_required?: boolean; ip?: string; + ipv6?: string; }) => { return peerRequest.put( { @@ -99,6 +100,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/peers/PeerAddressCell.tsx b/src/modules/peers/PeerAddressCell.tsx index 2de803ce..742e8f94 100644 --- a/src/modules/peers/PeerAddressCell.tsx +++ b/src/modules/peers/PeerAddressCell.tsx @@ -54,6 +54,7 @@ export default function PeerAddressCell({ peer }: Props) { className={"dark:text-nb-gray-400 font-mono font-thin text-xs"} > {peer.ip} + {peer.ipv6 && `, ${peer.ipv6}`} diff --git a/src/modules/peers/PeerAddressTooltipContent.tsx b/src/modules/peers/PeerAddressTooltipContent.tsx index edc2761b..0d199283 100644 --- a/src/modules/peers/PeerAddressTooltipContent.tsx +++ b/src/modules/peers/PeerAddressTooltipContent.tsx @@ -38,6 +38,21 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => { } /> + {peer.ipv6 && ( + } + label={"NetBird IPv6"} + value={ + + {peer.ipv6} + + } + /> + )} } label={"Public IP"} diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx index 17e4d4c6..d280e335 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,6 +19,7 @@ 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"; type Props = { account: Account; @@ -38,6 +40,16 @@ 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] = 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 +76,32 @@ export default function NetworkSettingsTab({ account }: Readonly) { const { hasChanges, updateRef } = useHasChanges([ customDNSDomain, networkRange, + networkRangeV6, + ipv6GroupNames, ]); const saveChanges = async () => { const updatedSettings = { ...account.settings, + ipv6_enabled_groups: ipv6EnabledGroups.map((g) => g.id).filter((id): id is string => !!id), }; 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 +114,7 @@ export default function NetworkSettingsTab({ account }: Readonly) { }) .then(() => { mutate("/accounts"); - updateRef([customDNSDomain, networkRange]); + updateRef([customDNSDomain, networkRange, networkRangeV6, ipv6GroupNames]); }), loadingMessage: "Updating network settings...", }); @@ -124,6 +149,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 +186,8 @@ export default function NetworkSettingsTab({ account }: Readonly) { !hasChanges || !permission.settings.update || !!domainError || - !!networkRangeError + !!networkRangeError || + !!networkRangeV6Error } onClick={saveChanges} > @@ -216,6 +253,51 @@ export default function NetworkSettingsTab({ account }: Readonly) {
+
+
+
+ + + 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)} + /> +
+
+
+ +
+ + + 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"); }; + From 492bbbd24736f29db3dd30cbb8c20c50c76e4141 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 26 Mar 2026 10:41:18 +0100 Subject: [PATCH 2/4] Fix PeerContext update type and use group IDs for dirty-checking --- src/contexts/PeerProvider.tsx | 1 + src/modules/settings/NetworkSettingsTab.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/contexts/PeerProvider.tsx b/src/contexts/PeerProvider.tsx index d0a45f0b..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; diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx index d280e335..d9adc780 100644 --- a/src/modules/settings/NetworkSettingsTab.tsx +++ b/src/modules/settings/NetworkSettingsTab.tsx @@ -46,8 +46,12 @@ export default function NetworkSettingsTab({ account }: Readonly) { const [ipv6EnabledGroups, setIpv6EnabledGroups] = useGroupHelper({ initial: account.settings?.ipv6_enabled_groups, }); - const ipv6GroupNames = useMemo( - () => ipv6EnabledGroups.map((g) => g.name).sort(), + const ipv6GroupIds = useMemo( + () => + ipv6EnabledGroups + .map((g) => g.id) + .filter((id): id is string => !!id) + .sort(), [ipv6EnabledGroups], ); @@ -77,7 +81,7 @@ export default function NetworkSettingsTab({ account }: Readonly) { customDNSDomain, networkRange, networkRangeV6, - ipv6GroupNames, + ipv6GroupIds, ]); const saveChanges = async () => { From d156e539a7b52d5033044a71516f557acb75c524 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Thu, 26 Mar 2026 10:49:57 +0100 Subject: [PATCH 3/4] Fix remaining ipv6GroupNames reference --- src/modules/settings/NetworkSettingsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/settings/NetworkSettingsTab.tsx b/src/modules/settings/NetworkSettingsTab.tsx index d9adc780..756d9e96 100644 --- a/src/modules/settings/NetworkSettingsTab.tsx +++ b/src/modules/settings/NetworkSettingsTab.tsx @@ -118,7 +118,7 @@ export default function NetworkSettingsTab({ account }: Readonly) { }) .then(() => { mutate("/accounts"); - updateRef([customDNSDomain, networkRange, networkRangeV6, ipv6GroupNames]); + updateRef([customDNSDomain, networkRange, networkRangeV6, ipv6GroupIds]); }), loadingMessage: "Updating network settings...", }); From c8d22d58b03161aac2a78c5134ce66965998fcbc Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Fri, 27 Mar 2026 12:23:28 +0100 Subject: [PATCH 4/4] Add ip_version query param support to browser SSH --- src/app/(remote-access)/peer/ssh/page.tsx | 19 +++++++++++++------ src/modules/remote-access/ssh/useSSH.ts | 2 ++ .../remote-access/ssh/useSSHQueryParams.ts | 10 +++++++++- src/modules/remote-access/useNetBirdClient.ts | 2 ++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/app/(remote-access)/peer/ssh/page.tsx b/src/app/(remote-access)/peer/ssh/page.tsx index e24035b2..ef8c7eb3 100644 --- a/src/app/(remote-access)/peer/ssh/page.tsx +++ b/src/app/(remote-access)/peer/ssh/page.tsx @@ -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} /> ) : ( @@ -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; 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, ); }, [],