mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f34b3d8331 | ||
|
|
107e326750 | ||
|
|
3303c5de99 | ||
|
|
a0d497239b | ||
|
|
ce006c5f28 | ||
|
|
9e9b88d36c | ||
|
|
f1a1ec27cf | ||
|
|
97bb3742ea | ||
|
|
881a9d7a83 | ||
|
|
a867170355 | ||
|
|
fd13579e50 | ||
|
|
e6d5faf314 | ||
|
|
b13bce56d8 | ||
|
|
e92ce98700 | ||
|
|
63cb86957a | ||
|
|
ddf5d30045 | ||
|
|
5acf358ab8 | ||
|
|
66f4eb4dd6 | ||
|
|
c907fd9475 | ||
|
|
a6780ea7ee |
No files matched your search
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -73,6 +73,7 @@ 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() {
|
||||
@@ -432,6 +433,12 @@ const PeerOverviewTabContent = () => {
|
||||
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"}>
|
||||
@@ -465,12 +472,12 @@ const PeerOverviewTabContent = () => {
|
||||
<div>
|
||||
<Label>Remote Access</Label>
|
||||
<HelpText>
|
||||
Connect directly to this peer via{" "}
|
||||
{isSSHSupported ? "SSH or RDP" : "RDP"}.
|
||||
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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}`;
|
||||
@@ -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);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -46,4 +46,5 @@ export interface PeerLocalFlags {
|
||||
rosenpass_enabled: boolean;
|
||||
rosenpass_permissive: boolean;
|
||||
server_ssh_allowed: boolean;
|
||||
server_vnc_allowed: boolean;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ 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";
|
||||
|
||||
@@ -253,6 +254,7 @@ export default function PeerActionCell() {
|
||||
<DropdownMenuSeparator />
|
||||
<SSHButton peer={peer} isDropdown={true} />
|
||||
<RDPButton peer={peer} isDropdown={true} />
|
||||
<VNCButton peer={peer} isDropdown={true} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -5,8 +5,50 @@ 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: the server is not ours, so any operating
|
||||
* system may be running one.
|
||||
* 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,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"}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
interface RDPQueryParams {
|
||||
peerId: string | null;
|
||||
ipVersion: string | null;
|
||||
}
|
||||
|
||||
export function useRDPQueryParams() {
|
||||
@@ -11,15 +12,17 @@ export function useRDPQueryParams() {
|
||||
const router = useRouter();
|
||||
const [params, setParams] = useState<RDPQueryParams>({
|
||||
peerId: null,
|
||||
ipVersion: null,
|
||||
});
|
||||
const [, setLocalQueryParams] = useLocalStorage("netbird-query-params", "");
|
||||
|
||||
useEffect(() => {
|
||||
const peerId = searchParams.get("id");
|
||||
const ipVersion = searchParams.get("ip_version");
|
||||
|
||||
// If all params are present in URL, use them
|
||||
if (peerId) {
|
||||
setParams({ peerId });
|
||||
setParams({ peerId, ipVersion });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,14 +42,19 @@ export function useRDPQueryParams() {
|
||||
|
||||
const urlParams = new URLSearchParams(paramsString);
|
||||
const storedPeerId = urlParams.get("id");
|
||||
const storedIpVersion = urlParams.get("ip_version");
|
||||
|
||||
if (storedPeerId) {
|
||||
const newSearchParams = new URLSearchParams();
|
||||
newSearchParams.set("id", storedPeerId);
|
||||
if (storedIpVersion) {
|
||||
newSearchParams.set("ip_version", storedIpVersion);
|
||||
}
|
||||
|
||||
router.replace(`/peer/rdp?${newSearchParams.toString()}`);
|
||||
setParams({
|
||||
peerId: storedPeerId,
|
||||
ipVersion: storedIpVersion,
|
||||
});
|
||||
|
||||
// Clear stored params after restoring
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { IPVersion } from "@/modules/remote-access/IPVersionSelect";
|
||||
import { useIronRDPInputHandler } from "./useIronRDPInputHandler";
|
||||
import {
|
||||
CertificatePromptInfo,
|
||||
@@ -24,6 +25,8 @@ export interface RDPCredentials {
|
||||
password: string;
|
||||
domain?: string;
|
||||
port: number;
|
||||
// ipVersion selects the IP version used to reach the peer.
|
||||
ipVersion?: IPVersion;
|
||||
}
|
||||
|
||||
interface RDPConnection {
|
||||
|
||||
@@ -24,6 +24,10 @@ import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
IPVersionSelect,
|
||||
type IPVersion,
|
||||
} from "@/modules/remote-access/IPVersionSelect";
|
||||
import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH";
|
||||
|
||||
type Props = {
|
||||
@@ -41,6 +45,7 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
|
||||
|
||||
const initialPort = usesStandardSSHPort(peer.version) ? "22" : "44338";
|
||||
const [port, setPort] = useState(initialPort);
|
||||
const [ipVersion, setIpVersion] = useState<IPVersion>("4");
|
||||
|
||||
const userNameError = useMemo(() => {
|
||||
if (username?.length === 0) return "Username cannot be empty";
|
||||
@@ -61,9 +66,10 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
|
||||
const openSSHWindow = () => {
|
||||
const encodedUsername = encodeURIComponent(username.trim());
|
||||
const encodedPort = encodeURIComponent(port.trim());
|
||||
const ipVersionParam = ipVersion === "6" ? "&ip_version=6" : "";
|
||||
|
||||
window.open(
|
||||
`/peer/ssh?id=${peer.id}&user=${encodedUsername}&port=${encodedPort}`,
|
||||
`/peer/ssh?id=${peer.id}&user=${encodedUsername}&port=${encodedPort}${ipVersionParam}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=800,height=450,left=100,top=100,location=no,toolbar=no,menubar=no,status=no",
|
||||
);
|
||||
@@ -92,7 +98,9 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
|
||||
placeholder={"root"}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
customSuffix={`@${peer.ip}`}
|
||||
customSuffix={`@${
|
||||
ipVersion === "6" && peer.ipv6 ? peer.ipv6 : peer.ip
|
||||
}`}
|
||||
data-1p-ignore
|
||||
autoComplete={"off"}
|
||||
error={userNameError}
|
||||
@@ -122,6 +130,17 @@ export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
|
||||
@@ -35,6 +35,17 @@ type NetBirdState = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
// TemporaryConnectResult is returned by connectTemporary. targetPubKey is
|
||||
// the destination peer's identity public key reported by management;
|
||||
// null on the early-exit path. keySessionId is the opaque handle for
|
||||
// the per-session X25519 keypair the wasm helper minted (the private
|
||||
// half stays inside wasm).
|
||||
export type TemporaryConnectResult = {
|
||||
connected: boolean;
|
||||
targetPubKey: string | null;
|
||||
keySessionId: string | null;
|
||||
};
|
||||
|
||||
class NetBirdStore {
|
||||
private state: NetBirdState = {
|
||||
status: NetBirdStatus.DISCONNECTED,
|
||||
@@ -265,13 +276,20 @@ export const useNetBirdClient = () => {
|
||||
);
|
||||
|
||||
const connectTemporary = useCallback(
|
||||
async (peerId: string, rules?: string[]) => {
|
||||
async (
|
||||
peerId: string,
|
||||
rules?: string[],
|
||||
): Promise<TemporaryConnectResult> => {
|
||||
const currentStatus = netBirdStore.getState().status;
|
||||
if (
|
||||
currentStatus === NetBirdStatus.CONNECTING ||
|
||||
currentStatus === NetBirdStatus.CONNECTED
|
||||
) {
|
||||
return currentStatus === NetBirdStatus.CONNECTED;
|
||||
return {
|
||||
connected: currentStatus === NetBirdStatus.CONNECTED,
|
||||
targetPubKey: null,
|
||||
keySessionId: null,
|
||||
};
|
||||
}
|
||||
|
||||
netBirdStore.setState({ status: NetBirdStatus.CONNECTING });
|
||||
@@ -285,15 +303,36 @@ export const useNetBirdClient = () => {
|
||||
: trim(
|
||||
`${browser.name.toLowerCase()}-${browser.version.toLowerCase()}-browser-client`,
|
||||
);
|
||||
await peerRequest.post(
|
||||
// Mint a per-session X25519 keypair inside wasm; only the
|
||||
// session-id handle and the public key cross the JS boundary.
|
||||
// The wasm bundle must be initialized first.
|
||||
await initialize();
|
||||
let sessionPub: string | undefined;
|
||||
let keySessionId: string | null = null;
|
||||
const wasmGenerate = (window as any).netbirdGenerateVNCSessionKey;
|
||||
if (typeof wasmGenerate === "function") {
|
||||
const sk = wasmGenerate();
|
||||
if (
|
||||
sk &&
|
||||
typeof sk === "object" &&
|
||||
typeof sk.publicKey === "string"
|
||||
) {
|
||||
sessionPub = sk.publicKey;
|
||||
keySessionId = sk.sessionId ?? null;
|
||||
}
|
||||
}
|
||||
const resp = (await peerRequest.post(
|
||||
{
|
||||
name,
|
||||
wg_pub_key: keyPairs.publicKey,
|
||||
rules: rules ?? ["tcp/22022", "tcp/3389", "tcp/44338", "netbird-ssh/22"],
|
||||
...(sessionPub ? { session_pub_key: sessionPub } : {}),
|
||||
},
|
||||
`/${peerId}/temporary-access`,
|
||||
);
|
||||
return await connect(keyPairs.privateKey);
|
||||
)) as { target_pub_key?: string } | undefined;
|
||||
const targetPubKey = resp?.target_pub_key ?? null;
|
||||
const connected = await connect(keyPairs.privateKey);
|
||||
return { connected, targetPubKey, keySessionId };
|
||||
} catch (error) {
|
||||
netBirdStore.setState({ status: NetBirdStatus.DISCONNECTED });
|
||||
throw error;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import Button from "@components/Button";
|
||||
import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { CircleHelpIcon, ScreenShareIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
isMobileOS,
|
||||
isNetBirdVNCSupportedOnOS,
|
||||
} from "@/modules/remote-access/osSupport";
|
||||
import { VNCTooltip } from "@/modules/remote-access/vnc/VNCTooltip";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
isDropdown?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A window shaped roughly like what it will show. A phone is portrait, so the
|
||||
* default landscape window would letterbox its screen down to a strip between
|
||||
* two black margins. The peer's real dimensions are not known until the session
|
||||
* negotiates them, so this goes on the operating system.
|
||||
*
|
||||
* Not as narrow as a phone, though. The viewer's toolbar does not wrap and its
|
||||
* disconnected panels carry a sentence or two, so the window has to fit the
|
||||
* chrome as well as the screen. Letterboxing a little at the sides is the
|
||||
* cheaper of the two ways to be wrong.
|
||||
*/
|
||||
const viewerWindowSize = (os?: string) => {
|
||||
if (!isMobileOS(os)) {
|
||||
return { width: 1200, height: 800 };
|
||||
}
|
||||
// Bounded by the screen it opens on, so it is not clipped on a laptop.
|
||||
const height = Math.min(900, Math.max(560, window.screen.availHeight - 120));
|
||||
return { width: 760, height };
|
||||
};
|
||||
|
||||
export const VNCButton = ({ peer, isDropdown = false }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
// netbirdVNCAvailable covers both halves of the NetBird path: the client
|
||||
// must ship a capturer for the peer's system, and the peer must have the
|
||||
// server switched on. Either missing leaves only the external path, which
|
||||
// still works, so neither hides the action.
|
||||
const netbirdVNCAvailable =
|
||||
isNetBirdVNCSupportedOnOS(peer?.os) &&
|
||||
!!peer?.local_flags?.server_vnc_allowed;
|
||||
// Enabled without NetBird screen sharing too: the viewer can still offer a
|
||||
// third-party VNC server on the peer, and its setup screen is where that
|
||||
// choice belongs. A button disabled behind a tooltip could only describe the
|
||||
// one option, and would hide the other entirely.
|
||||
const disabled = !peer.connected || !permission.peers.update;
|
||||
const hasPermission = permission.peers.update;
|
||||
|
||||
const openVNCPage = () => {
|
||||
const { width, height } = viewerWindowSize(peer?.os);
|
||||
window.open(
|
||||
`/peer/vnc?id=${peer.id}`,
|
||||
"_blank",
|
||||
`noopener,noreferrer,width=${width},height=${height},left=100,top=100,location=no,toolbar=no,menubar=no,status=no`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<VNCTooltip
|
||||
isOnline={peer.connected}
|
||||
isVNCEnabled={netbirdVNCAvailable}
|
||||
isNetBirdVNCSupported={isNetBirdVNCSupportedOnOS(peer?.os)}
|
||||
hasPermission={hasPermission}
|
||||
side={isDropdown ? "left" : "top"}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<DropdownMenuItem
|
||||
onClick={openVNCPage}
|
||||
disabled={disabled}
|
||||
className={"w-full"}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<ScreenShareIcon size={14} className={"shrink-0"} />
|
||||
VNC
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={openVNCPage}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ScreenShareIcon size={16} />
|
||||
VNC
|
||||
{disabled && <CircleHelpIcon size={12} />}
|
||||
</Button>
|
||||
)}
|
||||
</VNCTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loaded 20 of 28 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user