mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2ed029fa8 | ||
|
|
e8ee2d1093 |
No files matched your search
Generated
-7
@@ -10,7 +10,6 @@
|
||||
"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",
|
||||
@@ -1710,12 +1709,6 @@
|
||||
"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,7 +25,6 @@
|
||||
"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",
|
||||
|
||||
@@ -70,10 +70,8 @@ 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() {
|
||||
@@ -432,13 +430,6 @@ 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"}>
|
||||
@@ -467,17 +458,14 @@ const PeerOverviewTabContent = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSSHSupported && <PeerSSHToggle />}
|
||||
<PeerSSHToggle />
|
||||
|
||||
<div>
|
||||
<Label>Remote Access</Label>
|
||||
<HelpText>
|
||||
Connect directly to this peer via {remoteAccessMethods}.
|
||||
</HelpText>
|
||||
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
|
||||
<div className="flex gap-3">
|
||||
<SSHButton peer={peer} />
|
||||
<RDPButton peer={peer} />
|
||||
<VNCButton peer={peer} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { sendErrorNotification } from "@/modules/remote-access/errorNotification";
|
||||
import { notify } from "@components/Notification";
|
||||
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";
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
export default function RDPPage() {
|
||||
const { peerId, ipVersion } = useRDPQueryParams();
|
||||
const { peerId } = useRDPQueryParams();
|
||||
|
||||
const {
|
||||
data: peer,
|
||||
@@ -34,7 +35,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} ipVersion={ipVersion} />
|
||||
<RDPSession key={peer.id} peer={peer} />
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
)}
|
||||
@@ -44,10 +45,9 @@ export default function RDPPage() {
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
ipVersion: string | null;
|
||||
};
|
||||
|
||||
function RDPSession({ peer, ipVersion }: Props) {
|
||||
function RDPSession({ peer }: Props) {
|
||||
const client = useNetBirdClient();
|
||||
const [isNetBirdConnecting, setIsNetBirdConnecting] = useState(false);
|
||||
const rdp = useRemoteDesktop(client);
|
||||
@@ -55,14 +55,20 @@ function RDPSession({ peer, ipVersion }: 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.
|
||||
@@ -127,7 +133,7 @@ function RDPSession({ peer, ipVersion }: Props) {
|
||||
if (!credentials) return;
|
||||
try {
|
||||
const result = await rdp.connect({
|
||||
hostname: rdpHost,
|
||||
hostname: peer.ip,
|
||||
port: credentials.port,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
@@ -146,7 +152,7 @@ function RDPSession({ peer, ipVersion }: Props) {
|
||||
setCredentialsModal(true);
|
||||
await reset();
|
||||
}
|
||||
}, [credentials, rdpHost, rdp, reset]);
|
||||
}, [credentials, peer.ip, rdp, reset]);
|
||||
|
||||
/**
|
||||
* Establish RDP session when NetBird connection is ready
|
||||
@@ -216,7 +222,6 @@ function RDPSession({ peer, ipVersion }: Props) {
|
||||
peer={peer}
|
||||
onConnect={connect}
|
||||
loading={isLoading}
|
||||
initialIpVersion={ipVersion}
|
||||
/>
|
||||
|
||||
{/* Certificate Modal */}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import {
|
||||
usesStandardSSHPort,
|
||||
isNativeSSHSupported,
|
||||
isNetbirdSSHProtocolSupported,
|
||||
} from "@utils/version";
|
||||
|
||||
@@ -83,14 +83,8 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
|
||||
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
|
||||
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
|
||||
|
||||
// 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;
|
||||
// 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}@${sshHost} - ${peer.hostname}`;
|
||||
@@ -101,7 +95,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
|
||||
if (isSSHConnected || isSSHConnecting) return;
|
||||
connected.current = false;
|
||||
try {
|
||||
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
@@ -111,7 +105,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
|
||||
hostname: sshHost,
|
||||
port: Number(port),
|
||||
username,
|
||||
ipVersion: sshIpVersion || undefined,
|
||||
ipVersion: ipVersion || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Reconnection failed:", error);
|
||||
@@ -128,7 +122,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
|
||||
connected.current = true;
|
||||
|
||||
try {
|
||||
const aclPort = usesStandardSSHPort(peer.version) ? "22022" : port;
|
||||
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
|
||||
const protocol = isNetbirdSSHProtocolSupported(peer.version)
|
||||
? "netbird-ssh"
|
||||
: "tcp";
|
||||
@@ -138,7 +132,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
|
||||
hostname: sshHost,
|
||||
port: Number(port),
|
||||
username,
|
||||
ipVersion: sshIpVersion || undefined,
|
||||
ipVersion: ipVersion || undefined,
|
||||
});
|
||||
if (res === SSHStatus.CONNECTED) {
|
||||
sshConnectedOnce.current = true;
|
||||
@@ -174,7 +168,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
|
||||
return (
|
||||
<DisconnectedMessage
|
||||
username={username}
|
||||
peerIp={sshHost}
|
||||
peerIp={peer.ip}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
);
|
||||
@@ -184,7 +178,7 @@ function SSHTerminal({ username, port, peer, ipVersion }: Props) {
|
||||
<>
|
||||
{session && <Terminal session={session} onClose={disconnect} />}
|
||||
{!isSSHConnected && (
|
||||
<LoadingMessage message={`Connecting to ${username}@${sshHost}...`} />
|
||||
<LoadingMessage message={`Connecting to ${username}@${peer.ip}...`} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -81,31 +81,6 @@ 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.
|
After Width: | Height: | Size: 290 KiB |
@@ -4,7 +4,6 @@ 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 };
|
||||
|
||||
@@ -22,12 +21,6 @@ const VALID_PARAMS = [
|
||||
"page_size",
|
||||
"user",
|
||||
"port",
|
||||
"ip_version",
|
||||
"mode",
|
||||
"quality",
|
||||
"scale",
|
||||
"resize",
|
||||
"cursor",
|
||||
];
|
||||
|
||||
function filterAllowedParams(raw: string): string {
|
||||
@@ -39,13 +32,6 @@ 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;
|
||||
};
|
||||
@@ -56,15 +42,6 @@ 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);
|
||||
@@ -82,7 +59,14 @@ export const SecureProvider = ({ children }: Props) => {
|
||||
try {
|
||||
const params = window.location.search.substring(1);
|
||||
if (params) {
|
||||
storeQueryParams(currentPath, params);
|
||||
const filtered = filterAllowedParams(params);
|
||||
if (filtered) {
|
||||
const data: StoredQueryParams = {
|
||||
path: currentPath,
|
||||
params: filtered,
|
||||
};
|
||||
localStorage.setItem(QUERY_PARAMS_KEY, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,16 @@ 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, label }: Props) {
|
||||
export default function FullScreenLoading({ fullScreen = true }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-4 w-screen",
|
||||
"flex items-center justify-center w-screen",
|
||||
fullScreen && "h-screen",
|
||||
)}
|
||||
>
|
||||
<LoadingIcon className="fill-netbird" size={44} />
|
||||
{label && <div className="text-sm text-nb-gray-400">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,5 +46,4 @@ export interface PeerLocalFlags {
|
||||
rosenpass_enabled: boolean;
|
||||
rosenpass_permissive: boolean;
|
||||
server_ssh_allowed: boolean;
|
||||
server_vnc_allowed: boolean;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface PostureCheck {
|
||||
geo_location_check?: GeoLocationCheck;
|
||||
peer_network_range_check?: PeerNetworkRangeCheck;
|
||||
process_check?: ProcessCheck;
|
||||
certificate_check?: CertificateCheck;
|
||||
};
|
||||
policies?: Policy[];
|
||||
active?: boolean;
|
||||
@@ -65,6 +66,10 @@ export interface Process {
|
||||
windows_path?: string;
|
||||
}
|
||||
|
||||
export interface CertificateCheck {
|
||||
ca_certificates: string[];
|
||||
}
|
||||
|
||||
export const windowsKernelVersions: SelectOption[] = [
|
||||
{ value: "10.0", label: "Windows 10" },
|
||||
{ value: "10.0.2", label: "Windows 11" },
|
||||
|
||||
@@ -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,11 +39,6 @@ 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
|
||||
@@ -80,60 +75,46 @@ 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>
|
||||
<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" && (
|
||||
{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}>
|
||||
<p className={"font-normal"}>
|
||||
If you are using NetBird via the Desktop Client, click on the
|
||||
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.
|
||||
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 > Advanced Settings</Mark> and enable SSH
|
||||
Root Login under the SSH tab.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<Lightbox image={sshImage} />
|
||||
</Steps.Step>
|
||||
)}
|
||||
|
||||
<Steps.Step step={2}>
|
||||
<p className={"font-normal"}>
|
||||
{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."}
|
||||
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
|
||||
@@ -147,8 +128,8 @@ export const PeerSSHInstructions = ({
|
||||
</Steps.Step>
|
||||
<Steps.Step step={3} line={false}>
|
||||
<p className={"font-normal"}>
|
||||
Once the NetBird SSH server is enabled on the client, <br />
|
||||
click <Mark>Finish Setup</Mark> below to complete the setup.
|
||||
Once the NetBird SSH server is allowed on the client, <br />
|
||||
click <Mark>Confirm & Enable</Mark> below to finish the setup.
|
||||
</p>
|
||||
</Steps.Step>
|
||||
</Steps>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { usesStandardSSHPort } from "@utils/version";
|
||||
import { isNativeSSHSupported } 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 &&
|
||||
usesStandardSSHPort(peer.version);
|
||||
isNativeSSHSupported(peer.version);
|
||||
|
||||
return {
|
||||
peerPolicies,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -26,12 +27,11 @@ 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,15 +126,21 @@ 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, isSSHSupported]);
|
||||
}, [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;
|
||||
|
||||
const toggleLoginExpiration = async () => {
|
||||
const text = peer.login_expiration_enabled ? "disabled" : "enabled";
|
||||
@@ -249,12 +255,11 @@ export default function PeerActionCell() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{peer.connected && (
|
||||
{showRemoteAccessItems && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<SSHButton peer={peer} isDropdown={true} />
|
||||
<RDPButton peer={peer} isDropdown={true} />
|
||||
<VNCButton peer={peer} isDropdown={true} />
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ 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";
|
||||
@@ -14,6 +16,10 @@ 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,212 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { Label } from "@components/Label";
|
||||
import { ModalClose, ModalFooter } from "@components/modal/Modal";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { Textarea } from "@components/Textarea";
|
||||
import { uniqueId } from "lodash";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
FileKey2Icon,
|
||||
MinusCircleIcon,
|
||||
PlusCircle,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CertificateCheck } from "@/interfaces/PostureCheck";
|
||||
import {
|
||||
isValidPEMCertificate,
|
||||
useCertificateFingerprints,
|
||||
} from "@/modules/posture-checks/helper/CertificateHelper";
|
||||
import { PostureCheckCard } from "@/modules/posture-checks/ui/PostureCheckCard";
|
||||
|
||||
type Props = {
|
||||
value?: CertificateCheck;
|
||||
onChange: (value: CertificateCheck | undefined) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const PostureCheckCertificate = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<PostureCheckCard
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
key={open ? 1 : 0}
|
||||
active={value?.ca_certificates && value.ca_certificates.length > 0}
|
||||
title={"Certificate"}
|
||||
description={
|
||||
"Restrict access to peers holding a certificate issued by your own certificate authority."
|
||||
}
|
||||
icon={<FileKey2Icon size={18} />}
|
||||
iconClass={"bg-gradient-to-tr from-purple-500 to-purple-400"}
|
||||
modalWidthClass={"max-w-2xl"}
|
||||
onReset={() => onChange(undefined)}
|
||||
>
|
||||
<CheckContent
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
onChange(v);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</PostureCheckCard>
|
||||
);
|
||||
};
|
||||
|
||||
type CACertificate = {
|
||||
id: string;
|
||||
pem: string;
|
||||
};
|
||||
|
||||
const newCertificate = (pem = ""): CACertificate => ({
|
||||
id: uniqueId("ca-certificate"),
|
||||
pem,
|
||||
});
|
||||
|
||||
const rowsFor = (pem: string) =>
|
||||
Math.min(Math.max(pem.split("\n").length + 1, 6), 32);
|
||||
|
||||
const CheckContent = ({ value, onChange, disabled }: Props) => {
|
||||
const [certificates, setCertificates] = useState<CACertificate[]>(
|
||||
value?.ca_certificates?.length
|
||||
? value.ca_certificates.map((pem) => newCertificate(pem))
|
||||
: [newCertificate()],
|
||||
);
|
||||
|
||||
const pems = useMemo(() => certificates.map((c) => c.pem), [certificates]);
|
||||
const fingerprints = useCertificateFingerprints(pems);
|
||||
|
||||
const errors = useMemo(
|
||||
() =>
|
||||
certificates.map((c) =>
|
||||
c.pem.trim() !== "" && !isValidPEMCertificate(c.pem)
|
||||
? "Please paste a valid PEM encoded certificate"
|
||||
: "",
|
||||
),
|
||||
[certificates],
|
||||
);
|
||||
|
||||
const hasErrorsOrIsEmpty =
|
||||
certificates.length === 0 ||
|
||||
certificates.some((c) => c.pem.trim() === "") ||
|
||||
errors.some((e) => e !== "");
|
||||
|
||||
const updateCertificate = (id: string, pem: string) => {
|
||||
setCertificates(certificates.map((c) => (c.id === id ? { ...c, pem } : c)));
|
||||
};
|
||||
|
||||
const removeCertificate = (id: string) => {
|
||||
setCertificates(certificates.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"flex flex-col px-8 gap-2 pb-6"}>
|
||||
<div className={"flex justify-between items-start gap-10 mt-2"}>
|
||||
<div>
|
||||
<Label>CA Certificates</Label>
|
||||
<HelpText className={""}>
|
||||
Paste the PEM encoded public certificate of the root or
|
||||
intermediate CA that issues your device certificates. Peers will
|
||||
only be allowed to connect if they hold a certificate issued by
|
||||
one of these CAs and prove possession of its private key.
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
{certificates.length > 0 && (
|
||||
<div className={"mb-2 flex flex-col gap-4 w-full"}>
|
||||
{certificates.map((c, index) => {
|
||||
return (
|
||||
<div key={c.id} className={"flex gap-2 items-start min-w-0"}>
|
||||
<div className={"w-full flex flex-col gap-1.5 min-w-0"}>
|
||||
<Textarea
|
||||
value={c.pem}
|
||||
rows={rowsFor(c.pem)}
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
error={errors[index]}
|
||||
className={
|
||||
"w-full font-mono text-xs leading-5 overflow-auto"
|
||||
}
|
||||
onChange={(e) => updateCertificate(c.id, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{fingerprints[index] && (
|
||||
<span
|
||||
className={
|
||||
"text-xs text-nb-gray-400 font-mono break-all"
|
||||
}
|
||||
>
|
||||
SHA-256 {fingerprints[index]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
variant={"default-outline"}
|
||||
onClick={() => removeCertificate(c.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant={"dotted"}
|
||||
size={"sm"}
|
||||
onClick={() => setCertificates([...certificates, newCertificate()])}
|
||||
className={"mt-1"}
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add CA Certificate
|
||||
</Button>
|
||||
</div>
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/manage-posture-checks#certificate-check"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Certificate Check
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={hasErrorsOrIsEmpty || disabled}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
ca_certificates: certificates.map((c) => c.pem.trim()),
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import Badge from "@components/Badge";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { FileKey2Icon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { CertificateCheck } from "@/interfaces/PostureCheck";
|
||||
import { useCertificateFingerprints } from "@/modules/posture-checks/helper/CertificateHelper";
|
||||
|
||||
type Props = {
|
||||
check?: CertificateCheck;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export const CertificateTooltip = ({ check, children }: Props) => {
|
||||
const fingerprints = useCertificateFingerprints(check?.ca_certificates ?? []);
|
||||
|
||||
return check ? (
|
||||
<FullTooltip
|
||||
className={"w-full min-w-0"}
|
||||
interactive={true}
|
||||
contentClassName={"p-0"}
|
||||
content={
|
||||
<div
|
||||
className={
|
||||
"text-neutral-300 text-sm max-w-xs flex flex-col gap-1 min-w-0"
|
||||
}
|
||||
>
|
||||
<div className={"px-4 pt-3"}>
|
||||
<span>
|
||||
<span className={"text-green-500 font-semibold"}>Allow only</span>{" "}
|
||||
peers holding a certificate issued by one of the following CAs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
className={
|
||||
"max-h-[275px] overflow-y-auto flex flex-col px-4 min-w-0"
|
||||
}
|
||||
>
|
||||
<div className={"flex flex-col gap-2 mt-1 text-xs mb-3.5 min-w-0"}>
|
||||
{check.ca_certificates.map((_, index) => {
|
||||
const fingerprint = fingerprints[index];
|
||||
return (
|
||||
<Badge
|
||||
key={index}
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"justify-start font-medium text-xs min-w-0"}
|
||||
>
|
||||
<span className={"mr-1.5"}>
|
||||
<FileKey2Icon size={12} />
|
||||
</span>
|
||||
<span
|
||||
className={"truncate inline-block font-mono"}
|
||||
title={fingerprint}
|
||||
>
|
||||
{fingerprint
|
||||
? `SHA-256 ${fingerprint}`
|
||||
: `CA certificate ${index + 1}`}
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FullTooltip>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const pemCertificateBlock =
|
||||
/-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/g;
|
||||
const base64 = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
// Returns the base64 bodies of all CERTIFICATE blocks, or undefined when the input is
|
||||
// not exclusively made of well-formed blocks.
|
||||
export const pemCertificateBodies = (pem: string): string[] | undefined => {
|
||||
const bodies: string[] = [];
|
||||
const rest = pem.replace(pemCertificateBlock, (_, body: string) => {
|
||||
bodies.push(body.replace(/\s+/g, ""));
|
||||
return "";
|
||||
});
|
||||
if (bodies.length === 0 || rest.trim() !== "") return undefined;
|
||||
const wellFormed = bodies.every(
|
||||
(b) => b.length > 0 && b.length % 4 === 0 && base64.test(b),
|
||||
);
|
||||
return wellFormed ? bodies : undefined;
|
||||
};
|
||||
|
||||
export const isValidPEMCertificate = (pem: string) =>
|
||||
pemCertificateBodies(pem) !== undefined;
|
||||
|
||||
// SHA-256 fingerprint of the first certificate in the PEM, as colon separated hex.
|
||||
export const certificateFingerprint = async (
|
||||
pem: string,
|
||||
): Promise<string | undefined> => {
|
||||
const bodies = pemCertificateBodies(pem);
|
||||
if (!bodies || typeof crypto === "undefined" || !crypto.subtle) return;
|
||||
const der = Uint8Array.from(atob(bodies[0]), (c) => c.charCodeAt(0));
|
||||
const digest = await crypto.subtle.digest("SHA-256", der);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
|
||||
.join(":");
|
||||
};
|
||||
|
||||
export const useCertificateFingerprints = (pems: string[]) => {
|
||||
const [fingerprints, setFingerprints] = useState<(string | undefined)[]>([]);
|
||||
const key = JSON.stringify(pems);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const list: string[] = JSON.parse(key);
|
||||
Promise.all(list.map(certificateFingerprint)).then((result) => {
|
||||
if (!cancelled) setFingerprints(result);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return fingerprints;
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { ExternalLinkIcon, LayoutList, ShieldCheck, Text } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import { PostureCheckCertificate } from "@/modules/posture-checks/checks/PostureCheckCertificate";
|
||||
import { PostureCheckGeoLocation } from "@/modules/posture-checks/checks/PostureCheckGeoLocation";
|
||||
import { PostureCheckNetBirdVersion } from "@/modules/posture-checks/checks/PostureCheckNetBirdVersion";
|
||||
import { PostureCheckOperatingSystem } from "@/modules/posture-checks/checks/PostureCheckOperatingSystem";
|
||||
@@ -56,7 +57,8 @@ export default function PostureCheckModal({
|
||||
!!check?.checks?.geo_location_check ||
|
||||
!!check?.checks?.os_version_check ||
|
||||
!!check?.checks?.peer_network_range_check ||
|
||||
!!check?.checks.process_check;
|
||||
!!check?.checks.process_check ||
|
||||
!!check?.checks?.certificate_check;
|
||||
const canCreate =
|
||||
!isEmpty(check?.name) &&
|
||||
isAtLeastOneCheckEnabled &&
|
||||
@@ -165,6 +167,18 @@ export default function PostureCheckModal({
|
||||
!permission.policies.create || !permission.policies.update
|
||||
}
|
||||
/>
|
||||
<PostureCheckCertificate
|
||||
value={check?.checks?.certificate_check}
|
||||
onChange={(v) =>
|
||||
setCheck({
|
||||
type: "certificate_check",
|
||||
payload: v,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
!permission.policies.create || !permission.policies.update
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</TabsContent>
|
||||
<TabsContent value={"general"} className={"pb-8 px-8"}>
|
||||
|
||||
Loaded 20 of 44 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user