Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/app/(dashboard)/peer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand All @@ -494,6 +496,23 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
key={showEditIPModal ? 1 : 0}
/>
</Modal>
<Modal open={showEditIPv6Modal} onOpenChange={setShowEditIPv6Modal}>
<EditIPv6Modal
onSuccess={(newIPv6) => {
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}
/>
</Modal>
<Card className={"w-full xl:w-1/2"}>
<Card.List>
<Card.ListItem
Expand Down Expand Up @@ -525,6 +544,37 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
}
/>

{peer.ipv6 && (
<Card.ListItem
copy
tooltip={false}
copyText={"NetBird IPv6 Address"}
label={
<>
<MapPin size={16} />
NetBird IPv6 Address
</>
}
valueToCopy={peer.ipv6}
value={
<div className="flex items-center gap-2 justify-between w-full">
<span>{peer.ipv6}</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();
setShowEditIPv6Modal(true);
}}
>
<PencilIcon size={14} />
</button>
)}
</div>
}
/>
)}

<Card.ListItem
copy
copyText={"Public IP Address"}
Expand Down Expand Up @@ -844,3 +894,81 @@ function EditIPModal({ onSuccess, peer }: Readonly<EditIPModalProps>) {
</ModalContent>
);
}

interface EditIPv6ModalProps {
onSuccess: (ipv6: string) => void;
peer: Peer;
}

function isValidIPv6(address: string): boolean {
return cidr.isValidAddress(address) && address.includes(":");
}

function EditIPv6Modal({ onSuccess, peer }: Readonly<EditIPv6ModalProps>) {
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]);
Comment on lines +911 to +915
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize once and validate consistently to avoid false errors/no-op updates.

Line 914 uses trim() for button disabling, but Line 922 validates untrimmed input. This can show an error while Save is enabled and allow whitespace-only “changes”.

Suggested fix
 function EditIPv6Modal({ onSuccess, peer }: Readonly<EditIPv6ModalProps>) {
   const [ipv6, setIPv6] = useState(peer.ipv6 || "");
   const [error, setError] = useState("");
+  const normalizedIPv6 = trim(ipv6);

   const isDisabled = useMemo(() => {
-    if (ipv6 === peer.ipv6) return true;
-    const trimmed = trim(ipv6);
-    return trimmed.length === 0 || !isValidIPv6(trimmed);
-  }, [ipv6, peer.ipv6]);
+    if (normalizedIPv6 === (peer.ipv6 || "")) return true;
+    return normalizedIPv6.length === 0 || !isValidIPv6(normalizedIPv6);
+  }, [normalizedIPv6, peer.ipv6]);

   React.useEffect(() => {
     switch (true) {
-      case ipv6 === peer.ipv6:
+      case normalizedIPv6 === (peer.ipv6 || ""):
         setError("");
         break;
-      case !isValidIPv6(ipv6):
+      case !isValidIPv6(normalizedIPv6):
         setError("Please enter a valid IPv6 address, e.g., fd00:1234::1");
         break;
       default:
         setError("");
         break;
     }
-  }, [ipv6, peer.ipv6]);
+  }, [normalizedIPv6, peer.ipv6]);
 ...
-              onClick={() => onSuccess(ipv6.trim())}
+              onClick={() => onSuccess(normalizedIPv6)}

Also applies to: 917-929, 964-965

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(dashboard)/peer/page.tsx around lines 911 - 915, Normalize the IPv6
input once before checks: inside the isDisabled useMemo compute a normalizedIpv6
= trim(ipv6) (and normalizedPeerIpv6 = trim(peer.ipv6) if peer value may contain
whitespace) and then use normalizedIpv6 for the length check and pass
normalizedIpv6 to isValidIPv6; compare normalizedIpv6 to normalizedPeerIpv6 for
equality so Save is disabled for no-op whitespace changes. Apply the same
pattern to the other related blocks handling ipv6 (the validation/save logic
around the same peer IPv6 fields) so all checks consistently use the trimmed
value.


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 (
<ModalContent maxWidthClass={"max-w-md"}>
<form>
<ModalHeader
title={"Edit Peer IPv6 Address"}
description={"Update the NetBird IPv6 address for this peer."}
color={"blue"}
/>

<div className={"p-default flex flex-col gap-4"}>
<div>
<Input
placeholder={"e.g., fd00:1234::1"}
value={ipv6}
onChange={(e) => setIPv6(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(ipv6.trim())}
disabled={isDisabled}
>
Save
</Button>
</div>
</ModalFooter>
</form>
</ModalContent>
);
}
19 changes: 13 additions & 6 deletions src/app/(remote-access)/peer/ssh/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,6 +48,7 @@ export default function SSHPage() {
peer={peer}
username={username}
port={port}
ipVersion={ipVersion}
/>
) : (
<LoadingMessage message={"Starting ssh session..."} />
Expand All @@ -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);
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions src/components/DeviceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
Expand Down
4 changes: 3 additions & 1 deletion src/components/PeerGroupSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -968,7 +968,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 = ({
Expand Down Expand Up @@ -1059,6 +1060,7 @@ const PeersList = ({
}
>
{res.ip}
{res.ipv6 && `, ${res.ipv6}`}
<RadioItem value={res.id} />
</div>
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/components/PeerSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -126,6 +127,7 @@ export function PeerSelector({
>
<MapPinIcon />
{value.ip}
{value.ipv6 && `, ${value.ipv6}`}
</div>
</div>
) : (
Expand Down Expand Up @@ -240,6 +242,7 @@ export function PeerSelector({
>
<MapPinIcon />
{option.ip}
{option.ipv6 && `, ${option.ipv6}`}
</div>
</FullTooltip>
);
Expand Down
3 changes: 3 additions & 0 deletions src/contexts/PeerProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -80,6 +81,7 @@ export default function PeerProvider({
inactivityExpiration?: boolean;
approval_required?: boolean;
ip?: string;
ipv6?: string;
}) => {
return peerRequest.put(
{
Expand All @@ -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}`,
);
Expand Down
2 changes: 2 additions & 0 deletions src/interfaces/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/Peer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface Peer {
id?: string;
name: string;
ip: string;
ipv6?: string;
connected: boolean;
created_at?: Date;
last_seen: Date;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
1 change: 1 addition & 0 deletions src/modules/peers/PeerAddressCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`}
</span>
</CopyToClipboardText>
</div>
Expand Down
15 changes: 15 additions & 0 deletions src/modules/peers/PeerAddressTooltipContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ export const PeerAddressTooltipContent = ({ peer }: Props) => {
</CopyToClipboardText>
}
/>
{peer.ipv6 && (
<ListItem
icon={<NetworkIcon 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"}
Expand Down
2 changes: 2 additions & 0 deletions src/modules/remote-access/ssh/useSSH.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface SSHConfig {
hostname: string;
port: number;
username: string;
ipVersion?: string;
}

interface SSHConnection {
Expand Down Expand Up @@ -71,6 +72,7 @@ export const useSSH = (client: any) => {
config.port,
config.username,
requiresJwt ? accessToken : undefined,
config.ipVersion,
);

ssh.onclose = () => {
Expand Down
Loading
Loading