diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index a5cd5504..14094cff 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -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 ( <> - - { - 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} - /> - + + ) { copyText={"NetBird IP Address"} label={ <> - + NetBird IP Address } valueToCopy={peer.ip} value={ -
- {peer.ip} - {permission.peers.update && ( - - )} -
+ setShowEditIPModal(true)} + /> } /> + {peer.ipv6 && ( + + + NetBird IPv6 Address + + } + valueToCopy={peer.ipv6} + value={ + setShowEditIPv6Modal(true)} + /> + } + /> + )} + - + Public IP Address } @@ -542,7 +579,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { copyText={"DNS label"} label={ <> - + Domain Name } @@ -560,7 +597,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { copyText={"Hostname"} label={ <> - + Hostname } @@ -570,7 +607,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Region } @@ -600,7 +637,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Operating System } @@ -611,7 +648,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Serial Number } @@ -623,7 +660,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Registered on } @@ -639,7 +676,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Last seen } @@ -656,7 +693,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + Agent Version } @@ -667,7 +704,7 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - + UI Version } @@ -765,82 +802,29 @@ function EditNameModal({ onSuccess, peer, initialName }: Readonly) { ); } -interface EditIPModalProps { - onSuccess: (ip: string) => void; - peer: Peer; -} - -function EditIPModal({ onSuccess, peer }: Readonly) { - 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 ( - -
- - -
-
- setIP(e.target.value)} - error={error} - /> -
- - Changes take effect when the peer reconnects. -
- - -
- - - - - -
-
- -
+
+ {value} + {canEdit && ( + + )} +
); } 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/components/Card.tsx b/src/components/Card.tsx index e2611761..935047d5 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -50,11 +50,11 @@ function CardListItem({ return (
  • -
    {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 ( + + +
    + + +
    +
    + setIP(e.target.value)} + error={error} + /> +
    + + Changes take effect when the peer reconnects. +
    + + +
    + + + + + +
    +
    + +
    +
    + ); +} 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) {
    +
    +
    +
    + + + 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"); }; +