Compare commits

...
Author SHA1 Message Date
Viktor Liu f34b3d8331 Handle a failed peer fetch, blocked storage and an unvalidated VNC port 2026-09-09 13:53:34 +02:00
Viktor Liu 107e326750 Reopen the VNC password prompt on a rejected password and cancel back to the form 2026-09-09 12:25:03 +02:00
Viktor Liu 3303c5de99 Drop the toolbar separator when nothing precedes End session 2026-09-09 12:14:47 +02:00
Viktor Liu a0d497239b Hide the VNC controls an external server cannot serve 2026-09-09 12:12:44 +02:00
Viktor Liu ce006c5f28 Fix VNC reconnect, error styling and the external connection messages 2026-09-09 12:11:17 +02:00
Viktor Liu 9e9b88d36c Render the RDP credentials error with the shared callout 2026-09-09 12:11:17 +02:00
Viktor Liu f1a1ec27cf Move the VNC connection choice into a modal and open access only once made 2026-09-09 11:37:13 +02:00
Viktor Liu 97bb3742ea Offer VNC on every system, gating only NetBird's own server by OS 2026-09-09 10:58:00 +02:00
Viktor Liu 881a9d7a83 Offer a VNC server already running on the peer alongside NetBird's 2026-09-09 10:31:22 +02:00
Viktor Liu a867170355 Keep the SSH IP version and disconnect message in sync with the dialed host 2026-09-07 18:47:21 +02:00
Viktor Liu fd13579e50 Shape the VNC viewer for a phone and drop its cursor toggle 2026-09-07 16:29:17 +02:00
Viktor Liu e6d5faf314 Derive VNC query params during render and type the IP version 2026-09-07 16:29:09 +02:00
Viktor Liu b13bce56d8 Sync the VNC toolbar drag ref from an effect, not during render 2026-09-07 16:29:09 +02:00
Viktor Liu e92ce98700 Keep the dot cursor off by default 2026-09-07 16:29:09 +02:00
Viktor Liu 63cb86957a Honour the dot-cursor setting, drop private noVNC calls and share the error toast 2026-09-07 16:29:08 +02:00
Viktor Liu ddf5d30045 Handle the paste gesture once, from the keydown only 2026-09-07 16:29:08 +02:00
Viktor Liu 5acf358ab8 Paste into the remote with Ctrl+V, not only the toolbar button 2026-09-07 16:29:08 +02:00
Viktor Liu 66f4eb4dd6 Expand the VNC toolbar on keyboard focus and show a focus ring 2026-09-07 16:29:08 +02:00
Viktor Liu c907fd9475 Add an End session button to the VNC toolbar 2026-09-07 16:29:08 +02:00
Viktor Liu a6780ea7ee Add embedded VNC viewer with noVNC integration 2026-09-07 16:29:08 +02:00
Viktor Liu 9a3fc271da Add SSH support for Android peers 2026-09-07 16:27:59 +02:00
36 changed files with 2203 additions and 222 deletions

No files matched your search

+7
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@novnc/novnc": "^1.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -1709,6 +1710,12 @@
"node": ">=12.4.0"
}
},
"node_modules/@novnc/novnc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz",
"integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==",
"license": "MPL-2.0"
},
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
+1
View File
@@ -25,6 +25,7 @@
"dependencies": {
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@novnc/novnc": "^1.7.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
+14 -2
View File
@@ -70,8 +70,10 @@ import ReverseProxiesProvider, {
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { VNCButton } from "@/modules/remote-access/vnc/VNCButton";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
export default function PeerPage() {
@@ -430,6 +432,13 @@ const PeerOverviewTabContent = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { selectedGroups, setSelectedGroups } = usePeerSettings();
const isSSHSupported = isSSHSupportedOnOS(peer?.os);
// Named in the order the buttons appear below, and only the ones that do.
// RDP and VNC are unconditional: those servers need not be ours, so any
// system may be running one.
const remoteAccessMethods = [isSSHSupported && "SSH", "RDP", "VNC"]
.filter(Boolean)
.join(", ");
return (
<div className={"px-8"}>
@@ -458,14 +467,17 @@ const PeerOverviewTabContent = () => {
</div>
)}
<PeerSSHToggle />
{isSSHSupported && <PeerSSHToggle />}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<HelpText>
Connect directly to this peer via {remoteAccessMethods}.
</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
<VNCButton peer={peer} />
</div>
</div>
</div>
+12 -17
View File
@@ -1,8 +1,7 @@
"use client";
import { notify } from "@components/Notification";
import { sendErrorNotification } from "@/modules/remote-access/errorNotification";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { IconCircleX } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
@@ -24,7 +23,7 @@ import {
import { cn } from "@utils/helpers";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
const { peerId, ipVersion } = useRDPQueryParams();
const {
data: peer,
@@ -35,7 +34,7 @@ export default function RDPPage() {
return (
<div className={"w-screen h-screen overflow-hidden fixed inset-0"}>
{peerId && peer && !isLoading ? (
<RDPSession key={peer.id} peer={peer} />
<RDPSession key={peer.id} peer={peer} ipVersion={ipVersion} />
) : (
<FullScreenLoading />
)}
@@ -45,9 +44,10 @@ export default function RDPPage() {
type Props = {
peer: Peer;
ipVersion: string | null;
};
function RDPSession({ peer }: Props) {
function RDPSession({ peer, ipVersion }: Props) {
const client = useNetBirdClient();
const [isNetBirdConnecting, setIsNetBirdConnecting] = useState(false);
const rdp = useRemoteDesktop(client);
@@ -55,20 +55,14 @@ function RDPSession({ peer }: Props) {
const [credentials, setCredentials] = useState<RDPCredentials | null>(null);
const connected = useRef(false);
// Dial the IPv6 address when IPv6 was selected, IPv4 otherwise.
const rdpHost =
credentials?.ipVersion === "6" && peer.ipv6 ? peer.ipv6 : peer.ip;
useEffect(() => {
document.title = `${peer.name} - ${peer.ip} - RDP`;
}, [peer.ip, peer.name, connected, rdp]);
const sendErrorNotification = (title: string, message: string) => {
notify({
title: title,
description: message,
icon: <IconCircleX size={24} />,
backgroundColor: "bg-red-500",
duration: 10000,
});
};
/**
* Reset the RDP session state but keep the NetBird client connected,
* so a retry does not pay the full engine reconnect.
@@ -133,7 +127,7 @@ function RDPSession({ peer }: Props) {
if (!credentials) return;
try {
const result = await rdp.connect({
hostname: peer.ip,
hostname: rdpHost,
port: credentials.port,
username: credentials.username,
password: credentials.password,
@@ -152,7 +146,7 @@ function RDPSession({ peer }: Props) {
setCredentialsModal(true);
await reset();
}
}, [credentials, peer.ip, rdp, reset]);
}, [credentials, rdpHost, rdp, reset]);
/**
* Establish RDP session when NetBird connection is ready
@@ -222,6 +216,7 @@ function RDPSession({ peer }: Props) {
peer={peer}
onConnect={connect}
loading={isLoading}
initialIpVersion={ipVersion}
/>
{/* Certificate Modal */}
+15 -9
View File
@@ -13,7 +13,7 @@ import {
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import {
isNativeSSHSupported,
usesStandardSSHPort,
isNetbirdSSHProtocolSupported,
} from "@utils/version";
@@ -83,8 +83,14 @@ function SSHTerminal({ username, port, peer, ipVersion }: 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;
// Dial the IPv6 address when ip_version=6 is requested and the peer has one,
// IPv4 otherwise. peer.ip is always IPv4, so a request for "6" on a peer
// without an IPv6 address has to fall back on the version too, or the dialer
// is told to use a family the host doesn't belong to.
const requestedIPv6 = ipVersion === "6" ? peer.ipv6 : undefined;
const sshHost = requestedIPv6 ?? peer.ip;
const sshIpVersion =
ipVersion === "6" ? (requestedIPv6 ? "6" : "4") : ipVersion;
useEffect(() => {
document.title = `${username}@${sshHost} - ${peer.hostname}`;
@@ -95,7 +101,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
@@ -105,7 +111,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
ipVersion: sshIpVersion || undefined,
});
} catch (error) {
console.error("Reconnection failed:", error);
@@ -122,7 +128,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
connected.current = true;
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
const protocol = isNetbirdSSHProtocolSupported(peer.version)
? "netbird-ssh"
: "tcp";
@@ -132,7 +138,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
hostname: sshHost,
port: Number(port),
username,
ipVersion: ipVersion || undefined,
ipVersion: sshIpVersion || undefined,
});
if (res === SSHStatus.CONNECTED) {
sshConnectedOnce.current = true;
@@ -168,7 +174,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
return (
<DisconnectedMessage
username={username}
peerIp={peer.ip}
peerIp={sshHost}
onReconnect={handleReconnect}
/>
);
@@ -178,7 +184,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
<>
{session && <Terminal session={session} onClose={disconnect} />}
{!isSSHConnected && (
<LoadingMessage message={`Connecting to ${username}@${peer.ip}...`} />
<LoadingMessage message={`Connecting to ${username}@${sshHost}...`} />
)}
</>
);
File diff suppressed because it is too large. Load diff
+25
View File
@@ -81,6 +81,31 @@ p {
}
/**
* VNC viewport: noVNC writes the cursor inline on the canvas it creates
* inside our container, not on the container itself. When there's no
* remote sprite yet the inline value is `cursor: none`, and once a Cursor
* pseudo-encoded sprite arrives it becomes `cursor: url(...)`. The :not()
* rule below forces the browser default arrow as long as the inline
* style isn't using a url(), so the user has a visible pointer in the
* gap before any sprite is delivered and the remote sprite still wins
* once it shows up. Skip this for vnc-cursor-remote so the second rule
* below can actually hide the pointer when compositing is on.
*/
.vnc-cursor:not(.vnc-cursor-remote) canvas:not([style*="url("]) {
cursor: default !important;
}
/**
* In "show remote cursor" mode the server composites its cursor sprite
* directly into the captured framebuffer, so the local pointer must be
* hidden to avoid two stacked cursors. Matches both the empty/none and
* url() inline cases.
*/
.vnc-cursor-remote canvas {
cursor: none !important;
}
.timescape {
@apply flex items-center gap-[1px] rounded-md py-2 px-3 select-none w-fit cursor-text bg-nb-gray-900;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

+24 -8
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useEffect } from "react";
const QUERY_PARAMS_KEY = "netbird-query-params";
const PRESERVE_QUERY_PARAMS_PATHS = ["/peer/ssh", "/peer/rdp", "/peer/vnc"];
type StoredQueryParams = { path: string; params: string };
@@ -21,6 +22,12 @@ const VALID_PARAMS = [
"page_size",
"user",
"port",
"ip_version",
"mode",
"quality",
"scale",
"resize",
"cursor",
];
function filterAllowedParams(raw: string): string {
@@ -32,6 +39,13 @@ function filterAllowedParams(raw: string): string {
return output.toString();
}
function storeQueryParams(path: string, rawParams: string) {
const filtered = filterAllowedParams(rawParams);
if (!filtered) return;
const data: StoredQueryParams = { path, params: filtered };
localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(data));
}
type Props = {
children: React.ReactNode;
};
@@ -42,6 +56,15 @@ export const SecureProvider = ({ children }: Props) => {
useEffect(() => {
if (isAuthenticated) {
try {
const params = window.location.search.substring(1);
// Persist on every render while on a remote-access path. The OIDC
// library can fire a redirect synchronously when an access token
// expires, before the !isAuthenticated branch of this effect ever
// runs, so the peer id would be lost across the round-trip otherwise.
if (PRESERVE_QUERY_PARAMS_PATHS.includes(currentPath) && params) {
storeQueryParams(currentPath, params);
return;
}
const stored = localStorage.getItem(QUERY_PARAMS_KEY);
if (stored && !window.location.search) {
const data: StoredQueryParams = JSON.parse(stored);
@@ -59,14 +82,7 @@ export const SecureProvider = ({ children }: Props) => {
try {
const params = window.location.search.substring(1);
if (params) {
const filtered = filterAllowedParams(params);
if (filtered) {
const data: StoredQueryParams = {
path: currentPath,
params: filtered,
};
localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(data));
}
storeQueryParams(currentPath, params);
}
} catch (e) {}
}
+6 -2
View File
@@ -3,16 +3,20 @@ import LoadingIcon from "@/assets/icons/LoadingIcon";
type Props = {
fullScreen?: boolean;
// label, when set, renders under the spinner so callers can spell out
// which step of a multi-stage flow is in progress.
label?: string;
};
export default function FullScreenLoading({ fullScreen = true }: Props) {
export default function FullScreenLoading({ fullScreen = true, label }: Props) {
return (
<div
className={cn(
"flex items-center justify-center w-screen",
"flex flex-col items-center justify-center gap-4 w-screen",
fullScreen && "h-screen",
)}
>
<LoadingIcon className="fill-netbird" size={44} />
{label && <div className="text-sm text-nb-gray-400">{label}</div>}
</div>
);
}
+1
View File
@@ -46,4 +46,5 @@ export interface PeerLocalFlags {
rosenpass_enabled: boolean;
rosenpass_permissive: boolean;
server_ssh_allowed: boolean;
server_vnc_allowed: boolean;
}
+55 -36
View File
@@ -11,19 +11,19 @@ import ModalHeader from "@components/modal/ModalHeader";
import Paragraph from "@components/Paragraph";
import Separator from "@components/Separator";
import Steps from "@components/Steps";
import { Lightbox } from "@components/ui/Lightbox";
import { Mark } from "@components/ui/Mark";
import { cn } from "@utils/helpers";
import { ExternalLinkIcon, PlusCircle, TerminalSquare } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import sshImage from "@/assets/ssh/ssh-client.png";
import { SegmentedTabs } from "@components/SegmentedTabs";
import AndroidIcon from "@/assets/icons/AndroidIcon";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { isNetbirdSSHProtocolSupported } from "@utils/version";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { PeerSSHPolicyModal } from "@/modules/peer/PeerSSHPolicyModal";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
type Props = {
open?: boolean;
@@ -39,6 +39,11 @@ export const PeerSSHInstructions = ({
peer,
}: Props) => {
const [client, setClient] = useState("cli");
// Peers already on v0.61.0+ don't need to be told which release started
// requiring a policy.
const peerRequiresSSHPolicy = isNetbirdSSHProtocolSupported(
peer?.version ?? "",
);
const [policyModal, setPolicyModal] = useState(false);
// Enabling the SSH server and root login require root, or an administrator on
@@ -75,46 +80,60 @@ export const PeerSSHInstructions = ({
<NetBirdIcon size={16} />
Desktop Client
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger value={"android"}>
<AndroidIcon size={16} className={"fill-nb-gray-400"} />
Android
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
</SegmentedTabs>
<Steps>
{client === "cli" ? (
<Steps.Step step={1}>
<p className={"font-normal"}>
If you are using NetBird via CLI, you can enable SSH by
running{" "}
{isWindows
? "these commands in an elevated prompt"
: "these commands as root"}
. Run the first one only if NetBird is already running. On a
machine where you do not have those rights, an administrator
has to run them.
</p>
<Code codeToCopy={`${prefix}netbird down`}>
<Code.Line>{`${prefix}netbird down`}</Code.Line>
</Code>
<Code>
<Code.Line>{`${prefix}netbird up --allow-server-ssh --enable-ssh-root`}</Code.Line>
</Code>
</Steps.Step>
) : (
<Steps.Step step={1}>
<Steps.Step step={1}>
{client === "cli" && (
<>
<p className={"font-normal"}>
If you are using NetBird via CLI, you can enable SSH by
running{" "}
{isWindows
? "these commands in an elevated prompt"
: "these commands as root"}
. Run the first one only if NetBird is already running. On a
machine where you do not have those rights, an administrator
has to run them.
</p>
<Code codeToCopy={`${prefix}netbird down`}>
<Code.Line>{`${prefix}netbird down`}</Code.Line>
</Code>
<Code>
<Code.Line>{`${prefix}netbird up --allow-server-ssh --enable-ssh-root`}</Code.Line>
</Code>
</>
)}
{client === "gui" && (
<p className={"font-normal"}>
If you are using NetBird via the Desktop Client, click on the
NetBird tray icon, go to <Mark>Settings</Mark> and click{" "}
<Mark>Allow SSH</Mark>. If you want to enable Root Login go to{" "}
<Mark>Settings &gt; Advanced Settings</Mark> and enable SSH
Root Login under the SSH tab.
NetBird tray icon, open <Mark>Settings</Mark> and turn on{" "}
<Mark>Enable SSH Server</Mark> on the <Mark>SSH</Mark> tab. To
log in as {isWindows ? "an administrator" : "root"}, enable{" "}
<Mark>Allow Root Login</Mark> on the same tab.
</p>
<Lightbox image={sshImage} />
</Steps.Step>
)}
)}
{client === "android" && (
<p className={"font-normal"}>
If you are using NetBird on Android, open the app, tap{" "}
<Mark>Settings</Mark>, then <Mark>Advanced</Mark> and turn on{" "}
<Mark>Enable SSH Server</Mark>.
</p>
)}
</Steps.Step>
<Steps.Step step={2}>
<p className={"font-normal"}>
Starting from NetBird v0.61.0, SSH requires an explicit access
control policy to allow SSH connections to this machine.
{peerRequiresSSHPolicy
? "SSH requires an explicit access control policy to allow SSH connections to this machine."
: "Starting from NetBird v0.61.0, SSH requires an explicit access control policy to allow SSH connections to this machine."}
</p>
<div className={"mt-2"}>
<Button
@@ -128,8 +147,8 @@ export const PeerSSHInstructions = ({
</Steps.Step>
<Steps.Step step={3} line={false}>
<p className={"font-normal"}>
Once the NetBird SSH server is allowed on the client, <br />
click <Mark>Confirm & Enable</Mark> below to finish the setup.
Once the NetBird SSH server is enabled on the client, <br />
click <Mark>Finish Setup</Mark> below to complete the setup.
</p>
</Steps.Step>
</Steps>
+2 -2
View File
@@ -1,7 +1,7 @@
import useFetchApi from "@utils/api";
import { Peer } from "@/interfaces/Peer";
import { Policy } from "@/interfaces/Policy";
import { isNativeSSHSupported } from "@utils/version";
import { usesStandardSSHPort } from "@utils/version";
export const usePeerSSHPolicyCheck = (peer?: Peer) => {
const { data: policies, isLoading } = useFetchApi<Policy[]>(
@@ -66,7 +66,7 @@ export const usePeerSSHPolicyCheck = (peer?: Peer) => {
!hasSSHPolicy &&
!isLoading &&
!!peer?.ssh_enabled &&
isNativeSSHSupported(peer.version);
usesStandardSSHPort(peer.version);
return {
peerPolicies,
+8 -13
View File
@@ -8,7 +8,6 @@ import {
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconInfoCircle } from "@tabler/icons-react";
import {
CheckCircle2,
@@ -27,11 +26,12 @@ import { useSWRConfig } from "swr";
import { useBypass, useBypassedPeers } from "@/cloud/edr/useBypass";
import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
import { useIntegrations } from "@/modules/integrations/edr/useIntegrations";
import { isSSHSupportedOnOS } from "@/modules/remote-access/osSupport";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { VNCButton } from "@/modules/remote-access/vnc/VNCButton";
import InlineLink from "@components/InlineLink";
import { useDialog } from "@/contexts/DialogProvider";
@@ -126,21 +126,15 @@ export default function PeerActionCell() {
const showRevokeBypass = isBypassed && canBypass;
const showApprovalGroup = showApprove || showBypass || showRevokeBypass;
const isSSHSupported = isSSHSupportedOnOS(peer?.os);
const showSSHButton = useMemo(() => {
if (!isSSHSupported) return false;
const isClientSSHEnabled = peer?.local_flags?.server_ssh_allowed;
const isDashboardSSHEnabled = peer?.ssh_enabled;
if (isDashboardSSHEnabled) return true;
return !isClientSSHEnabled;
}, [peer]);
// The Connect column previously hosted SSH / RDP entry points. We
// fold those into the action menu — gated on a non-mobile, online
// peer — so the table loses a column and the connect affordance is
// one click away inside the three-dot menu.
const peerOs = getOperatingSystem(peer?.os);
const isMobile =
peerOs === OperatingSystem.ANDROID || peerOs === OperatingSystem.IOS;
const showRemoteAccessItems = !isMobile && !!peer.connected;
}, [peer, isSSHSupported]);
const toggleLoginExpiration = async () => {
const text = peer.login_expiration_enabled ? "disabled" : "enabled";
@@ -255,11 +249,12 @@ export default function PeerActionCell() {
</>
)}
{showRemoteAccessItems && (
{peer.connected && (
<>
<DropdownMenuSeparator />
<SSHButton peer={peer} isDropdown={true} />
<RDPButton peer={peer} isDropdown={true} />
<VNCButton peer={peer} isDropdown={true} />
</>
)}
-6
View File
@@ -4,11 +4,9 @@ import {
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconChevronDown } from "@tabler/icons-react";
import * as React from "react";
import { usePeer } from "@/contexts/PeerProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { cn } from "@utils/helpers";
@@ -16,10 +14,6 @@ import { cn } from "@utils/helpers";
export const PeerConnectButton = () => {
const { peer } = usePeer();
const isConnected = peer.connected;
const os = getOperatingSystem(peer?.os);
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
if (isMobile) return;
return isConnected ? (
<>
@@ -0,0 +1,31 @@
import { SelectDropdown } from "@components/select/SelectDropdown";
import * as React from "react";
// IPVersion is the IP version used to reach a peer. Only these two values are
// meaningful, so the select and everything it feeds are typed on them rather
// than on a bare string.
export type IPVersion = "4" | "6";
type Props = {
value: IPVersion;
onChange: (value: IPVersion) => void;
// hasIPv6 enables the IPv6 option; peers without an IPv6 address
// cannot be reached over IPv6.
hasIPv6: boolean;
};
// IPVersionSelect picks the IP version used to connect to the peer.
export function IPVersionSelect({ value, onChange, hasIPv6 }: Props) {
return (
<SelectDropdown
value={value}
// SelectDropdown is typed on plain strings; the options below are the
// only two it can emit, and anything else narrows to "4".
onChange={(v) => onChange(v === "6" ? "6" : "4")}
options={[
{ value: "4", label: "IPv4" },
{ value: "6", label: "IPv6", disabled: !hasIPv6 },
]}
/>
);
}
@@ -0,0 +1,17 @@
import { IconCircleX } from "@tabler/icons-react";
import { notify } from "@components/Notification";
/**
* sendErrorNotification shows a remote-access failure as a toast. Shared by the
* VNC and RDP pages so both report failures with the same wording, icon and
* dwell time.
*/
export function sendErrorNotification(title: string, message: string): void {
notify({
title,
description: message,
icon: <IconCircleX size={24} />,
backgroundColor: "bg-red-500",
duration: 10000,
});
}
+54
View File
@@ -0,0 +1,54 @@
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
/**
* Check if the NetBird SSH server can run on the peer's operating system.
* The client ships it everywhere except iOS.
*
* There is no equivalent for RDP, nor for VNC as a whole: those servers need
* not be ours, so any operating system may be running one.
*/
export const isSSHSupportedOnOS = (os?: string) =>
getOperatingSystem(os ?? "") !== OperatingSystem.IOS;
const VNC_SUPPORTED_OS = new Set([
OperatingSystem.LINUX,
OperatingSystem.WINDOWS,
OperatingSystem.APPLE,
OperatingSystem.FREEBSD,
OperatingSystem.ANDROID,
]);
/**
* Check if NetBird's own VNC server can run on the peer's operating system.
* Listed rather than excluded, unlike SSH: the server needs a way to capture
* the screen, which is a per-system piece of work rather than something the
* client has everywhere.
*
* This gates the NetBird server specifically, not VNC access as such. A peer
* on any system may be running a third-party VNC server the viewer can reach,
* so it must not be used to decide whether to offer VNC at all.
*/
export const isNetBirdVNCSupportedOnOS = (os?: string) =>
VNC_SUPPORTED_OS.has(getOperatingSystem(os ?? ""));
/** Check if the peer is a handheld, which is shaped and driven differently. */
export const isMobileOS = (os?: string) => {
const detected = getOperatingSystem(os ?? "");
return detected === OperatingSystem.ANDROID || detected === OperatingSystem.IOS;
};
const NO_REPORTED_CURSOR = new Set([
// Draws the cursor into the frames it captures, so asking for it again
// would show the viewer two.
OperatingSystem.APPLE,
// Has no pointer to report in the first place.
OperatingSystem.ANDROID,
]);
/**
* Check if the host can report its cursor separately from the screen. Where it
* cannot, the "show remote cursor" control has nothing to switch.
*/
export const hasReportedCursor = (os?: string) =>
!NO_REPORTED_CURSOR.has(getOperatingSystem(os ?? ""));
@@ -1,10 +1,8 @@
import Button from "@components/Button";
import { DropdownMenuItem } from "@components/DropdownMenu";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { CircleHelpIcon, MonitorIcon } from "lucide-react";
import * as React from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip";
@@ -13,18 +11,9 @@ type Props = {
isDropdown?: boolean;
};
const RDP_SUPPORTED_OS = new Set([
OperatingSystem.WINDOWS,
OperatingSystem.LINUX,
OperatingSystem.FREEBSD,
]);
export const RDPButton = ({ peer, isDropdown = false }: Props) => {
const { permission } = usePermissions();
const os = getOperatingSystem(peer?.os);
if (!RDP_SUPPORTED_OS.has(os)) return null;
const disabled = !peer.connected || !permission.peers.update;
const hasPermission = permission.peers.update;
@@ -1,29 +1,35 @@
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import HelpText from "@components/HelpText";
import InlineLink from "@components/InlineLink";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { Peer } from "@/interfaces/Peer";
import Paragraph from "@components/Paragraph";
import Separator from "@components/Separator";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { IconLoader2 } from "@tabler/icons-react";
import {
ChevronsLeftRightEllipsis,
ExternalLinkIcon,
KeyRoundIcon,
MonitorIcon,
TriangleAlertIcon,
User2,
} from "lucide-react";
import Separator from "@components/Separator";
import Paragraph from "@components/Paragraph";
import InlineLink from "@components/InlineLink";
import Button from "@components/Button";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import {
type IPVersion,
IPVersionSelect,
} from "@/modules/remote-access/IPVersionSelect";
import {
RDP_DOCS_LINK,
RDPCredentials,
} from "@/modules/remote-access/rdp/useRemoteDesktop";
import { IconLoader2 } from "@tabler/icons-react";
type Props = {
open: boolean;
@@ -31,6 +37,7 @@ type Props = {
onConnect?: (credentials: RDPCredentials) => void;
error?: string;
loading?: boolean;
initialIpVersion?: string | null;
};
export const RDPCredentialsModal = ({
@@ -39,6 +46,7 @@ export const RDPCredentialsModal = ({
onConnect,
error,
loading,
initialIpVersion,
}: Props) => {
const defaultUsername =
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS
@@ -48,6 +56,11 @@ export const RDPCredentialsModal = ({
const [password, setPassword] = useState("");
const [port, setPort] = useState("3389");
// Anything the URL carried other than a usable "6" falls back to "4", so the
// state is always one of the two the select and the credentials accept.
const [ipVersion, setIpVersion] = useState<IPVersion>(
initialIpVersion === "6" && peer.ipv6 ? "6" : "4",
);
const userNameError = useMemo(() => {
if (username?.length === 0) return "Username cannot be empty";
@@ -93,8 +106,9 @@ export const RDPCredentialsModal = ({
password,
domain: parsedDomain,
port: Number(port),
ipVersion,
});
}, [hasAnyError, onConnect, username, password, port]);
}, [hasAnyError, onConnect, username, password, port, ipVersion]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -124,16 +138,9 @@ export const RDPCredentialsModal = ({
}}
>
{error && (
<div className={"bg-red-50 border border-red-200 rounded-md p-4"}>
<div
className={
"flex items-center gap-2 text-red-800 font-medium mb-1"
}
>
Error
</div>
<p className={"text-sm text-red-700"}>{error}</p>
</div>
<Callout variant={"error"} icon={<TriangleAlertIcon size={14} />}>
{error}
</Callout>
)}
<div>
<Label>Username & Password</Label>
@@ -198,6 +205,17 @@ export const RDPCredentialsModal = ({
}
/>
</div>
<div>
<Label>IP Version</Label>
<HelpText>
The IP version used to connect to the remote host.
</HelpText>
<IPVersionSelect
value={ipVersion}
onChange={setIpVersion}
hasIPv6={!!peer.ipv6}
/>
</div>
</form>
<ModalFooter className={"items-center"}>
Loaded 20 of 36 files, more files were not shown because too many files have changed in this diff. Show more