Compare commits

...

2 Commits

Author SHA1 Message Date
Eduard Gert
9a401733b3 Fix toggle for p2p policies (#501)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-31 13:21:23 +01:00
Eduard Gert
07b6895380 Sync SSH & RDP changes (#495)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2025-10-16 14:44:26 +02:00
36 changed files with 1749 additions and 284 deletions

View File

@@ -13,5 +13,6 @@
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID"
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
"wasmPath": "$NETBIRD_WASM_PATH"
}

View File

@@ -61,11 +61,12 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH:-https://pkgs.netbird.io/wasm/client}
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
# replace ENVs in the config
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS"
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH"
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"

View File

@@ -1,13 +1,14 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import { Callout } from "@components/Callout";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import useFetchApi from "@utils/api";
import { ExternalLinkIcon } from "lucide-react";
import { ArrowUpRightIcon, ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import PeersProvider from "@/contexts/PeersProvider";
@@ -59,6 +60,17 @@ export default function NetworkRoutes() {
</InlineLink>
in our documentation.
</Paragraph>
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
<span>
We recommend using the new Networks concept to easier visualise
and manage access to your resources.{" "}
<InlineLink href={"/networks"}>
Go to Networks
<ArrowUpRightIcon size={14} />
</InlineLink>
</span>
</Callout>
</div>
<RestrictedAccess hasAccess={permission.routes.read}>

View File

@@ -1,7 +1,15 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import Card from "@components/Card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Separator from "@components/Separator";
@@ -12,12 +20,14 @@ import { cn } from "@utils/helpers";
import {
ArrowUpRightIcon,
HelpCircle,
MoreVertical,
PencilLineIcon,
ServerIcon,
ShieldCheckIcon,
ShieldXIcon,
Trash2,
} from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
@@ -25,8 +35,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import NetworkModal from "@/modules/networks/NetworkModal";
import { NetworkProvider } from "@/modules/networks/NetworkProvider";
import {
NetworkProvider,
useNetworksContext,
} from "@/modules/networks/NetworkProvider";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
@@ -77,35 +89,24 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
<div className={"flex justify-between max-w-6xl"}>
<div
className={cn(
"flex items-center",
!network.description && "gap-2",
)}
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
{permission.networks.update && (
<button
className={
"flex items-center gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md cursor-pointer"
}
onClick={() => setNetworkModal(true)}
>
<PencilLineIcon size={18} />
</button>
)}
<NetworkModal
open={networkModal}
setOpen={setNetworkModal}
onUpdated={() => {
mutate(`/networks/${network.id}`);
}}
network={network}
/>
<div
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
<NetworkProvider network={network}>
<NetworkActions />
</NetworkProvider>
</div>
</div>
@@ -124,6 +125,56 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
);
}
function NetworkActions() {
const { permission } = usePermissions();
const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext();
const router = useRouter();
if (!network) return;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Button variant={"secondary"} className={"!px-3"}>
<MoreVertical size={16} className={"shrink-0"} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
onClick={() => openEditNetworkModal(network)}
disabled={!permission.networks.update}
>
<div className={"flex gap-3 items-center"}>
<PencilLineIcon size={14} className={"shrink-0"} />
Rename
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
deleteNetwork(network).then(() => router.push("/networks"))
}
variant={"danger"}
disabled={!permission.networks.delete}
>
<div className={"flex gap-3 items-center"}>
<Trash2 size={14} className={"shrink-0"} />
Delete
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const isHighlyAvailable = !!(
network?.routing_peers_count && network?.routing_peers_count >= 2
@@ -154,7 +205,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
const policyCount = network.policies?.length ?? 0;
return (
<Card>
<Card className={"w-full lg:w-1/2"}>
<Card.List>
<Card.ListItem
tooltip={false}

View File

@@ -4,8 +4,6 @@ import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import Card from "@components/Card";
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import HelpText from "@components/HelpText";
import { Input } from "@components/Input";
import { Label } from "@components/Label";
@@ -38,12 +36,10 @@ import {
FlagIcon,
Globe,
History,
LockIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
TerminalSquare,
TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -66,6 +62,7 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
@@ -83,9 +80,8 @@ export default function PeerPage() {
const peerKey = useMemo(() => {
let id = peer?.id ?? "";
let ssh = peer?.ssh_enabled ? "1" : "0";
let expiration = peer?.login_expiration_enabled ? "1" : "0";
return `${id}-${ssh}-${expiration}`;
return `${id}-${expiration}`;
}, [peer]);
if (isRestricted) {
@@ -107,7 +103,7 @@ export default function PeerPage() {
);
return peer && !isLoading ? (
<PeerProvider peer={peer} key={peerId}>
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
<PeerOverview key={peerKey} />
</PeerProvider>
) : (
@@ -141,8 +137,7 @@ function PeerOverview() {
const PeerGeneralInformation = () => {
const router = useRouter();
const { mutate } = useSWRConfig();
const { peer, user, peerGroups, openSSHDialog, update } = usePeer();
const [ssh, setSsh] = useState(peer.ssh_enabled);
const { peer, user, peerGroups, update } = usePeer();
const [name, setName] = useState(peer.name);
const [showEditNameModal, setShowEditNameModal] = useState(false);
const [loginExpiration, setLoginExpiration] = useState(
@@ -161,7 +156,6 @@ const PeerGeneralInformation = () => {
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
@@ -174,7 +168,6 @@ const PeerGeneralInformation = () => {
if (permission.peers.update) {
const updateRequest = update({
name: newName ?? name,
ssh,
loginExpiration,
inactivityExpiration,
});
@@ -190,7 +183,6 @@ const PeerGeneralInformation = () => {
mutate("/peers/" + peer.id);
mutate("/groups");
updateHasChangedRef([
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
@@ -314,41 +306,7 @@ const PeerGeneralInformation = () => {
)}
</div>
<FullTooltip
content={
<div
className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}
>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</div>
}
interactive={false}
className={"w-full block"}
disabled={permission.peers.update}
>
<FancyToggleSwitch
value={ssh}
disabled={!permission.peers.update}
onChange={(set) =>
!set
? setSsh(false)
: openSSHDialog().then((confirm) => setSsh(confirm))
}
label={
<>
<TerminalSquare size={16} />
SSH Access
</>
}
helpText={
"Enable the SSH server on this peer to access the machine via an secure shell."
}
/>
</FullTooltip>
<PeerSSHToggle />
{/* Remote Access Buttons */}
<div>

View File

@@ -4,6 +4,7 @@ import { notify } from "@components/Notification";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { IconCircleX } from "@tabler/icons-react";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Loader2Icon } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { Peer } from "@/interfaces/Peer";
@@ -19,7 +20,6 @@ import {
NetBirdStatus,
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
import { cn } from "@utils/helpers";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
@@ -31,7 +31,7 @@ export default function RDPPage() {
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
return (
<div className={"w-screen h-screen overflow-hidden"}>
<div className={"w-screen h-screen overflow-hidden fixed inset-0"}>
{peerId && peer && !isLoading ? (
<RDPSession key={peer.id} peer={peer} />
) : (
@@ -55,7 +55,7 @@ function RDPSession({ peer }: Props) {
useEffect(() => {
document.title = `${peer.name} - ${peer.ip} - RDP`;
}, []);
}, [peer.ip, peer.name, connected, rdp]);
const sendErrorNotification = (title: string, message: string) => {
notify({
@@ -104,6 +104,7 @@ function RDPSession({ peer }: Props) {
port: credentials.port,
username: credentials.username,
password: credentials.password,
domain: credentials.domain,
width: window.innerWidth,
height: window.innerHeight,
});

View File

@@ -2,6 +2,7 @@
import { PageNotFound } from "@components/ui/PageNotFound";
import useFetchApi, { ErrorResponse } from "@utils/api";
import { isNativeSSHSupported } from "@utils/version";
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
import React, { useEffect, useRef } from "react";
import type { Peer } from "@/interfaces/Peer";
@@ -86,7 +87,8 @@ function SSHTerminal({ username, port, peer }: Props) {
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const rules = [`tcp/${port}`];
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const rules = [`tcp/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
@@ -107,7 +109,8 @@ function SSHTerminal({ username, port, peer }: Props) {
if (connected.current) return;
connected.current = true;
try {
const rules = [`tcp/${port}`];
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const rules = [`tcp/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

@@ -309,7 +309,7 @@ export function PeerGroupSelector({
"flex items-center gap-2 border-nb-gray-700 flex-wrap h-full"
}
>
{resource && showResources && (
{resource && (
<ResourceBadge
className={"py-[3px]"}
resource={resources?.find((r) => r.id === resource.id)}

View File

@@ -34,31 +34,34 @@ export default function PolicyDirection({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [disabled]);
const isNetworkResource =
!!destinationResource && destinationResource?.type !== "peer";
const topBadgeClass = useMemo(() => {
if (destinationResource) return "blueDark";
if (isNetworkResource) return "blueDark";
if (value === "bi") return "green";
if (value === "in") return "blueDark";
return "gray";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
const topArrowClass = useMemo(() => {
if (destinationResource) return "fill-sky-500";
if (isNetworkResource) return "fill-sky-500";
if (value === "bi") return "fill-green-500";
if (value === "in") return "fill-sky-500";
return "fill-gray-500";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
const bottomBadgeClass = useMemo(() => {
if (destinationResource) return "gray";
if (isNetworkResource) return "gray";
if (value === "bi") return "green";
return "gray";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
const bottomArrowClass = useMemo(() => {
if (destinationResource) return "fill-gray-500";
if (isNetworkResource) return "fill-gray-500";
if (value === "bi") return "fill-green-500";
return "fill-gray-500";
}, [value, destinationResource]);
}, [value, isNetworkResource]);
return (
<button

View File

@@ -1,18 +1,21 @@
import { notify } from "@components/Notification";
import SkeletonPeerDetail from "@components/skeletons/SkeletonPeerDetail";
import { useApiCall } from "@utils/api";
import React, { useMemo } from "react";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useUsers } from "@/contexts/UsersProvider";
import { Group, GroupPeer } from "@/interfaces/Group";
import { Peer } from "@/interfaces/Peer";
import { User } from "@/interfaces/User";
import { PeerSSHInstructions } from "@/modules/peer/PeerSSHInstructions";
type Props = {
children: React.ReactNode;
peer: Peer;
isPeerDetailPage?: boolean;
};
const PeerContext = React.createContext(
@@ -28,18 +31,25 @@ const PeerContext = React.createContext(
approval_required?: boolean;
ip?: string;
}) => Promise<Peer>;
openSSHDialog: () => Promise<boolean>;
toggleSSH: (newState: boolean) => Promise<void>;
setSSHInstructionsModal: (open: boolean) => void;
deletePeer: () => void;
isLoading: boolean;
},
);
export default function PeerProvider({ children, peer }: Props) {
export default function PeerProvider({
children,
peer,
isPeerDetailPage = false,
}: Props) {
const user = usePeerUser(peer);
const { peerGroups, isLoading } = usePeerGroups(peer);
const peerRequest = useApiCall<Peer>("/peers", true);
const { confirm } = useDialog();
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
const [sshInstructionsModal, setSSHInstructionsModal] = useState(false);
const deletePeer = async () => {
const choice = await confirm({
@@ -94,25 +104,20 @@ export default function PeerProvider({ children, peer }: Props) {
);
};
const openSSHDialog = async (): Promise<boolean> => {
return await confirm({
title: `Enable SSH Server for ${peer.name}?`,
description: (
<div className={"flex flex-col gap-2"}>
<div>
Enabling this option allows remote SSH access to this machine from
other connected network participants.
</div>
<div>
Make sure SSH is allowed in the NetBird Client under{" "}
<span className={"text-white"}>Settings &rarr; Allow SSH</span>
</div>
</div>
),
confirmText: "Enable",
cancelText: "Cancel",
type: "warning",
maxWidthClass: "max-w-lg",
const toggleSSH = async (enable: boolean) => {
if (!permission.peers.update) return;
notify({
title: peer.name,
description: enable
? "SSH Access successfully enabled"
: "SSH Access successfully disabled",
promise: update({ ssh: enable }).then(() => {
isPeerDetailPage ? mutate(`/peers/${peer.id}`) : mutate("/peers");
setSSHInstructionsModal(false);
}),
loadingMessage: enable
? "Enabling SSH Access..."
: "Disabling SSH Access...",
});
};
@@ -123,16 +128,25 @@ export default function PeerProvider({ children, peer }: Props) {
peerGroups,
user,
update,
openSSHDialog,
toggleSSH,
setSSHInstructionsModal,
deletePeer,
isLoading,
}}
>
{sshInstructionsModal && (
<PeerSSHInstructions
open={sshInstructionsModal}
onOpenChange={setSSHInstructionsModal}
onSuccess={() => toggleSSH(true)}
/>
)}
{children}
</PeerContext.Provider>
) : (
) : isPeerDetailPage ? (
<SkeletonPeerDetail />
);
) : null;
}
/**

View File

@@ -289,7 +289,7 @@ export function AccessControlModalContent({
showRoutes={true}
showResources={false}
showPeers={true}
showResourceCounter={true}
showResourceCounter={false}
showPeerCount={allowEditPeers}
disableInlineRemoveGroup={false}
values={sourceGroups}

View File

@@ -37,6 +37,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
if (rule.destinationResource) {
rule.destinations = null;
}
if (rule.sourceResource) {
rule.sources = null;
}
});
updatePolicy(

View File

@@ -16,7 +16,9 @@ export default function AccessControlDirectionCell({
}, [policy]);
const bidirectional = firstRule ? firstRule.bidirectional : false;
const isSingleResource = !!firstRule?.destinationResource;
const isSingleResource =
!!firstRule?.destinationResource &&
firstRule?.destinationResource?.type !== "peer";
return (
<div className={"flex h-full"}>

View File

@@ -282,7 +282,10 @@ export const useAccessControl = ({
const hasPortSupport = (p: Protocol) => p === "tcp" || p === "udp";
const portDisabled = !hasPortSupport(protocol);
const isDestinationPeer = destinationResource?.type === "peer";
const destinationHasResources = useMemo(() => {
if (isDestinationPeer) return false;
if (destinationResource) return true;
return destinationGroups.some((group) => {
@@ -294,9 +297,10 @@ export const useAccessControl = ({
}
return false;
});
}, [destinationGroups, destinationResource]);
}, [destinationGroups, destinationResource, isDestinationPeer]);
const destinationOnlyResources = useMemo(() => {
if (isDestinationPeer) return false;
if (destinationResource) return true;
return (
@@ -318,13 +322,13 @@ export const useAccessControl = ({
return hasResources && !hasPeers;
})
);
}, [destinationGroups, destinationResource]);
}, [destinationGroups, destinationResource, isDestinationPeer]);
useEffect(() => {
if (destinationOnlyResources && direction !== "in") {
if (destinationOnlyResources && direction !== "in" && !isDestinationPeer) {
setDirection("in");
}
}, [destinationOnlyResources, direction, setDirection]);
}, [destinationOnlyResources, direction, setDirection, isDestinationPeer]);
return {
protocol,

View File

@@ -72,6 +72,8 @@ export default function useGroupHelper({ initial = [], peer }: Props) {
};
const removePeerFromGroup = async (g: Group) => {
if (g.name === "All") return Promise.resolve(g);
const newPeerGroups = g.peers?.filter((p) => {
const groupPeer = p as GroupPeer;
return groupPeer.id !== peer?.id;

View File

@@ -29,7 +29,7 @@ const NetworksContext = React.createContext(
resource?: NetworkResource,
) => void;
openPolicyModal: (network?: Network, resource?: NetworkResource) => void;
deleteNetwork: (network: Network) => void;
deleteNetwork: (network: Network) => Promise<void>;
deleteResource: (network: Network, resource: NetworkResource) => void;
deleteRouter: (network: Network, router: NetworkRouter) => void;
network?: Network;
@@ -124,15 +124,19 @@ export const NetworkProvider = ({ children, network }: Props) => {
if (!choice) return;
const promise = deleteCall({}, `/${network.id}`).then(() => {
mutate("/networks");
mutate("/groups");
});
notify({
title: network.name,
description: "Network deleted successfully.",
loadingMessage: "Deleting network...",
promise: deleteCall({}, `/${network.id}`).then(() => {
mutate("/networks");
mutate("/groups");
}),
promise,
});
return promise;
};
const deleteResource = async (
@@ -250,8 +254,9 @@ export const NetworkProvider = ({ children, network }: Props) => {
mutate("/networks");
await askForResource(network);
}}
onUpdated={() => {
onUpdated={(n) => {
mutate("/networks");
mutate(`/networks/${n.id}`);
}}
/>
<Modal

View File

@@ -50,7 +50,7 @@ export const NetworkInformationSquare = ({
)}
></div>
</div>
<div className={"mt-[0px] flex items-center flex-wrap"}>
<div className={"mt-[0px] flex items-start flex-wrap flex-col"}>
<p
className={cn(
"font-medium text-left whitespace-nowrap",

View File

@@ -13,7 +13,7 @@ export default function NetworkNameCell({ network }: Readonly<Props>) {
);
return (
<div className={"flex gap-4 items-center min-w-[300px] max-w-[300px]"}>
<div className={"flex gap-4 items-center min-w-[300px] max-w-[450px]"}>
<NetworkInformationSquare
name={network.name}
active={isActive}

View File

@@ -0,0 +1,111 @@
import Button from "@components/Button";
import Code from "@components/Code";
import InlineLink from "@components/InlineLink";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
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, TerminalSquare } from "lucide-react";
import * as React from "react";
import sshImage from "@/assets/ssh/ssh-client.png";
type Props = {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void;
};
export const PeerSSHInstructions = ({
open,
onOpenChange,
onSuccess,
}: Props) => {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent
maxWidthClass={cn("relative", "max-w-2xl")}
showClose={true}
>
<ModalHeader
icon={<TerminalSquare size={16} className={"text-netbird"} />}
title={"Enable SSH Access"}
description={
"Allow remote SSH access to this machine from other connected network participants. NetBird's embedded SSH server is running on port 44338."
}
color={"netbird"}
/>
<Separator />
<div className={"px-8 py-3 flex flex-col gap-0 z-0"}>
<Steps>
<Steps.Step step={1}>
<p className={"font-normal"}>
If you are using NetBird via CLI, you can enable SSH by running
</p>
<Code codeToCopy={"netbird down"}>
<Code.Line>{`netbird down # if NetBird is already running`}</Code.Line>
</Code>
<Code>
<Code.Line>{`netbird up --allow-server-ssh`}</Code.Line>
</Code>
</Steps.Step>
<Steps.Step step={2}>
<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> <br />
</p>
<Lightbox image={sshImage} />
</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.
</p>
</Steps.Step>
</Steps>
</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/ssh"}
target={"_blank"}
>
SSH
<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"}
onClick={onSuccess}
data-cy={"create-setup-key"}
>
Confirm & Enable
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,47 @@
import FancyToggleSwitch from "@components/FancyToggleSwitch";
import FullTooltip from "@components/FullTooltip";
import { LockIcon, TerminalSquare } from "lucide-react";
import * as React from "react";
import { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
export const PeerSSHToggle = () => {
const { permission } = usePermissions();
const { peer, toggleSSH, setSSHInstructionsModal } = usePeer();
return (
<>
<FullTooltip
content={
<div className={"flex gap-2 items-center !text-nb-gray-300 text-xs"}>
<LockIcon size={14} />
<span>
{`You don't have the required permissions to update this
setting.`}
</span>
</div>
}
interactive={false}
className={"w-full block"}
disabled={permission.peers.update}
>
<FancyToggleSwitch
value={peer.ssh_enabled}
disabled={!permission.peers.update}
onChange={(enable) =>
enable ? setSSHInstructionsModal(true) : toggleSSH(false)
}
label={
<>
<TerminalSquare size={16} />
SSH Access
</>
}
helpText={
"Enable the SSH server on this peer to access the machine via an secure shell."
}
/>
</FullTooltip>
</>
);
};

View File

@@ -24,7 +24,8 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton";
export default function PeerActionCell() {
const { peer, deletePeer, update, openSSHDialog } = usePeer();
const { peer, deletePeer, update, toggleSSH, setSSHInstructionsModal } =
usePeer();
const router = useRouter();
const { mutate } = useSWRConfig();
const { permission } = usePermissions();
@@ -48,21 +49,6 @@ export default function PeerActionCell() {
});
};
const toggleSSH = async () => {
const text = peer.ssh_enabled ? "disabled" : "enabled";
notify({
title: `SSH Server is ${text}`,
description: `The SSH Server for the peer ${peer.name} was successfully ${text}.`,
promise: update({
ssh: !peer.ssh_enabled,
}).then(() => {
mutate("/peers");
mutate("/groups");
}),
loadingMessage: "Updating SSH access...",
});
};
return (
<div className={"flex justify-end pr-4 gap-3"}>
<DropdownMenu modal={false}>
@@ -118,10 +104,8 @@ export default function PeerActionCell() {
<DropdownMenuItem
onClick={() =>
peer.ssh_enabled
? toggleSSH()
: openSSHDialog().then((enable) =>
enable ? toggleSSH() : null,
)
? toggleSSH(false)
: setSSHInstructionsModal(true)
}
disabled={!permission.peers.update}
>

View File

@@ -6,12 +6,12 @@ import {
import FullTooltip from "@components/FullTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { IconChevronDown } from "@tabler/icons-react";
import { cn } from "@utils/helpers";
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";
export const PeerConnectButton = () => {
const { peer } = usePeer();
@@ -50,7 +50,7 @@ export const PeerConnectButton = () => {
<FullTooltip
content={
<div className={"max-w-[200px] text-xs"}>
Connecting via SSH or RDP is only available when the peer is online.
Connecting via SSH or RDP is only available when the peer is online.
</div>
}
>

View File

@@ -9,7 +9,6 @@ import {
import MemoizedNetBirdIcon from "@components/ui/MemoizedNetBirdIcon";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { parseVersionString } from "@utils/version";
import { trim } from "lodash";
import { ArrowRightIcon, ArrowUpCircleIcon } from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
@@ -39,8 +38,6 @@ export default function PeerVersionCell({ version, os, serial }: Props) {
return <ArrowUpCircleIcon size={15} className={"text-netbird"} />;
}, []);
const isWasmClient = trim(os) === "js";
return (
<div className={"flex flex-col gap-1"}>
{updateAvailable ? (
@@ -114,7 +111,7 @@ export default function PeerVersionCell({ version, os, serial }: Props) {
>
<PeerOperatingSystemIcon os={os} />
{isWasmClient ? "Web Client" : os}
{os}
</div>
</FullTooltip>
)}

View File

@@ -259,10 +259,17 @@ export default function PeersTable({
const [showBrowserPeers, setShowBrowserPeers] = useState(false);
const withBrowserPeers = useCallback(
(condition: boolean) =>
peers?.filter((peer) =>
condition ? trim(peer.os) === "js" : trim(peer.os) !== "js",
) ?? [],
(condition: boolean) => {
const isWebClient = (peer: Peer) => {
return trim(peer?.os) == "js" || peer.kernel_version === "wasm";
};
return (
peers?.filter((peer) =>
condition ? isWebClient(peer) : !isWebClient(peer),
) ?? []
);
},
[peers],
);

View File

@@ -1,8 +1,13 @@
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import Button from "@components/Button";
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 { IconLoader2 } from "@tabler/icons-react";
import {
ChevronsLeftRightEllipsis,
ExternalLinkIcon,
@@ -10,18 +15,13 @@ import {
MonitorIcon,
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 { Peer } from "@/interfaces/Peer";
import {
RDP_DOCS_LINK,
RDPCredentials,
} from "@/modules/remote-access/rdp/useRemoteDesktop";
import { IconLoader2 } from "@tabler/icons-react";
type Props = {
open: boolean;
@@ -61,9 +61,31 @@ export const RDPCredentialsModal = ({
const handleConnect = useCallback(() => {
if (hasAnyError || !onConnect) return;
let parsedUsername = username;
let parsedDomain = "";
// Parse DOMAIN\username format
if (username.includes("\\")) {
const parts = username.split("\\");
if (parts.length === 2) {
parsedDomain = parts[0];
parsedUsername = parts[1];
}
}
// Parse username@domain format
else if (username.includes("@")) {
const parts = username.split("@");
if (parts.length === 2) {
parsedUsername = parts[0];
parsedDomain = parts[1];
}
}
onConnect({
username,
username: parsedUsername,
password,
domain: parsedDomain,
port: Number(port),
});
}, [hasAnyError, onConnect, username, password, port]);
@@ -111,11 +133,12 @@ export const RDPCredentialsModal = ({
<Label>Username & Password</Label>
<HelpText>
Enter the credentials required to authenticate with the remote
host.
host. For domain accounts, use DOMAIN\username or username@domain
format.
</HelpText>
<div className={"flex flex-col gap-2 w-full"}>
<Input
placeholder={"Administrator"}
placeholder={"Administrator or DOMAIN\\username"}
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}

View File

@@ -29,7 +29,6 @@ export interface RDPSession {
shutdown(): void;
sendInput(input: unknown): void;
onClipboardPaste?(content: ClipboardData): Promise<void>;
inputHandler?: IronRDPInputHandler;
}
interface TerminationInfo {
reason(): string;
@@ -57,11 +56,6 @@ interface RDPConfig {
declare global {
interface Window {
IronRDPBridge: IronRDPWASMBridge;
IronRDPInputHandler?: new (
ironrdp: IronRDPModule,
session: RDPSession,
canvas: HTMLCanvasElement,
) => IronRDPInputHandler;
initializeIronRDP: () => Promise<boolean>;
onIronRDPReady?: () => void;
createRDCleanPathProxy?: (
@@ -70,9 +64,6 @@ declare global {
) => Promise<string>;
}
}
interface IronRDPInputHandler {
destroy(): void;
}
const IRON_RDP_PKG = "/ironrdp-pkg/ironrdp_web.js";
@@ -115,7 +106,8 @@ export class IronRDPWASMBridge {
port: number,
username: string,
password: string,
canvas: HTMLCanvasElement,
domain?: string,
canvas?: HTMLCanvasElement,
enableClipboard = true,
netbirdClient?: {
createRDPProxy: (hostname: string, port: string) => Promise<string>;
@@ -132,9 +124,9 @@ export class IronRDPWASMBridge {
const config: RDPConfig = {
username,
password,
domain: "",
width: canvas.width || 1024,
height: canvas.height || 768,
domain: domain || "",
width: canvas?.width || 1024,
height: canvas?.height || 768,
enable_tls: true,
enable_credssp: true,
enable_nla: true,
@@ -177,9 +169,6 @@ export class IronRDPWASMBridge {
builder.authToken("");
const session = await builder.connect();
this.sessions.set(sessionId, session);
if (canvas) {
this.attachInputHandler(session, canvas);
}
if (enableClipboard) {
this.startClipboardEventListeners();
}
@@ -203,24 +192,7 @@ export class IronRDPWASMBridge {
this.handleLocalClipboardRequest();
});
}
private attachInputHandler(
session: RDPSession,
canvas: HTMLCanvasElement,
): void {
if (!window.IronRDPInputHandler) {
console.warn("IronRDPInputHandler not loaded - input will not work");
return;
}
if (!this.ironrdp) {
console.warn("IronRDP module not available");
return;
}
session.inputHandler = new window.IronRDPInputHandler(
this.ironrdp,
session,
canvas,
);
}
private startSession(session: RDPSession, sessionId: string): void {
session
.run()
@@ -234,9 +206,6 @@ export class IronRDPWASMBridge {
});
}
private cleanupSession(session: RDPSession, sessionId: string): void {
if (session.inputHandler) {
session.inputHandler.destroy();
}
this.sessions.delete(sessionId);
// Stop clipboard event listeners if no active sessions
@@ -244,12 +213,82 @@ export class IronRDPWASMBridge {
this.stopClipboardEventListeners();
}
}
private formatWSAError(wsaCode: number): string {
const wsaDescriptions: Record<number, string> = {
10004: "interrupted system call",
10009: "bad file descriptor",
10013: "permission denied",
10014: "bad address",
10022: "invalid argument",
10024: "too many open files",
10035: "resource temporarily unavailable",
10036: "operation now in progress",
10037: "operation already in progress",
10038: "socket operation on nonsocket",
10039: "destination address required",
10040: "message too long",
10041: "protocol wrong type for socket",
10042: "bad protocol option",
10043: "protocol not supported",
10044: "socket type not supported",
10045: "operation not supported",
10046: "protocol family not supported",
10047: "address family not supported by protocol family",
10048: "address already in use",
10049: "cannot assign requested address",
10050: "network is down",
10051: "network is unreachable",
10052: "network dropped connection on reset",
10053: "software caused connection abort",
10054: "connection reset by peer",
10055: "no buffer space available",
10056: "socket is already connected",
10057: "socket is not connected",
10058: "cannot send after socket shutdown",
10060: "connection timed out",
10061: "connection refused",
10064: "host is down",
10065: "no route to host",
10067: "too many processes",
10091: "network subsystem is unavailable",
10092: "Winsock version not supported",
10093: "successful WSAStartup not yet performed",
10101: "graceful shutdown in progress",
10109: "class type not found",
11001: "host not found",
11002: "nonauthoritative host not found",
11003: "this is a nonrecoverable error",
11004: "valid name, no data record of requested type",
};
return wsaDescriptions[wsaCode] || "unknown error";
}
private formatRDCleanPathError(backtraceMsg: string): string {
const wsaMatch = backtraceMsg.match(/WSA last error = (\d+)/);
if (wsaMatch) {
const wsaCode = parseInt(wsaMatch[1], 10);
const description = this.formatWSAError(wsaCode);
return `Connection failed: ${description} (WSA ${wsaCode})`;
}
const httpMatch = backtraceMsg.match(/HTTP status code = (\d+)/);
if (httpMatch) {
return `Connection failed: HTTP ${httpMatch[1]}`;
}
return backtraceMsg;
}
private logIronError(error: unknown): void {
const ironError = error as any;
if (!ironError || !ironError.__wbg_ptr) return;
try {
if (ironError.backtrace) {
console.error("IronRDP backtrace:", ironError.backtrace());
const backtraceMsg = ironError.backtrace();
const formattedMsg = this.formatRDCleanPathError(backtraceMsg);
console.error("IronRDP error:", formattedMsg);
console.debug("IronRDP backtrace:", backtraceMsg);
}
if (ironError.kind) {
const errorKind = ironError.kind();
@@ -269,13 +308,13 @@ export class IronRDPWASMBridge {
console.error("Could not extract IronError details:", e);
}
}
getSession(sessionId: string): RDPSession | null {
return this.sessions.get(sessionId) || null;
}
disconnect(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
if (session.inputHandler) {
session.inputHandler.destroy();
session.inputHandler = undefined;
}
if (session.shutdown) {
session.shutdown();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useIronRDPInputHandler } from "./useIronRDPInputHandler";
import {
CertificatePromptInfo,
useRDPCertificateHandler,
@@ -14,6 +15,7 @@ interface RDPConfig {
port: number;
username: string;
password: string;
domain?: string;
width?: number;
height?: number;
}
@@ -21,6 +23,7 @@ interface RDPConfig {
export interface RDPCredentials {
username: string;
password: string;
domain?: string;
port: number;
}
@@ -38,8 +41,7 @@ export enum RDPStatus {
CONNECTING = 2,
}
export const RDP_DOCS_LINK =
"https://docs.netbird.io/how-to/browser-client#rdp-connection";
export const RDP_DOCS_LINK = "https://docs.netbird.io/how-to/browser-client";
export const useRemoteDesktop = (client: any) => {
const [status, setStatus] = useState(RDPStatus.DISCONNECTED);
@@ -59,10 +61,20 @@ export const useRemoteDesktop = (client: any) => {
reject: (reason?: any) => void;
} | null>(null);
const [rdpSession, setRdpSession] = useState<any>(null);
const [ironrdpModule, setIronrdpModule] = useState<any>(null);
const { handleRDCleanPathResponse, acceptCertificate } =
useRDPCertificateHandler();
const certificateAccepted = useRef(false);
const { isActive, focusCanvas } = useIronRDPInputHandler({
ironrdp: ironrdpModule,
session: rdpSession,
canvas: canvasRef.current,
isConnected: status === RDPStatus.CONNECTED,
});
/**
* Reset the RDP state, optionally preserving config and/or certificate state
*/
@@ -75,6 +87,9 @@ export const useRemoteDesktop = (client: any) => {
) => {
session.current = null;
setStatus(RDPStatus.DISCONNECTED);
setRdpSession(null);
setIronrdpModule(null);
if (!options.preserveConfig) {
setConfig(null);
}
@@ -172,11 +187,17 @@ export const useRemoteDesktop = (client: any) => {
rdpConfig.port,
rdpConfig.username,
rdpConfig.password,
rdpConfig.domain,
canvas,
true,
client.client,
);
// Store the ironrdp module and session for the input handler hook
setIronrdpModule((client.ironRDPBridge as any).ironrdp || null);
const actualSession = client.ironRDPBridge.getSession(sessionId);
setRdpSession(actualSession);
session.current = {
id: sessionId,
disconnect: (options = {}) => {
@@ -192,6 +213,7 @@ export const useRemoteDesktop = (client: any) => {
};
setStatus(RDPStatus.CONNECTED);
lastConnectedConfigRef.current = rdpConfig;
canvasRef?.current?.focus();
return RDPStatus.CONNECTED;
} catch (err) {
const ironError = err as IronError;
@@ -232,6 +254,7 @@ export const useRemoteDesktop = (client: any) => {
setPendingCertificate(null);
certificatePromiseRef.current = null;
certificateAccepted.current = true;
canvasRef?.current?.focus();
},
[pendingCertificate, acceptCertificate],
);
@@ -287,6 +310,7 @@ export const useRemoteDesktop = (client: any) => {
await connect(newConfig);
} finally {
setIsResizing(false);
canvasRef?.current?.focus();
}
}, 1000);
};
@@ -318,6 +342,10 @@ export const useRemoteDesktop = (client: any) => {
session: session.current,
canvasRef,
// Input handler
inputHandlerActive: isActive,
focusCanvas,
// Certificate handling
pendingCertificate,
acceptCertificatePrompt,

View File

@@ -1,14 +1,14 @@
import Button from "@components/Button";
import { DropdownMenuItem } from "@components/DropdownMenu";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { CircleHelpIcon, TerminalIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
import { SSHTooltip } from "@/modules/remote-access/ssh/SSHTooltip";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
type Props = {
peer: Peer;
@@ -41,7 +41,8 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => {
)}
<div>
<SSHTooltip
disabled={!disabled}
isOnline={peer.connected}
isSSHEnabled={peer.ssh_enabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
>

View File

@@ -1,57 +1,99 @@
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { ExternalLinkIcon } from "lucide-react";
import { ArrowUpRightIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { usePeer } from "@/contexts/PeerProvider";
type Props = {
disabled?: boolean;
children?: React.ReactNode;
hasPermission?: boolean;
hasPermission: boolean;
isOnline?: boolean;
isSSHEnabled?: boolean;
side?: "top" | "right" | "bottom" | "left";
};
export const SSHTooltip = ({
disabled,
children,
hasPermission,
isOnline,
isSSHEnabled,
side = "top",
}: Props) => {
const [showTooltip, setShowTooltip] = useState(false);
const tooltipContent = () => {
if (!hasPermission) {
return <NoPermissionText />;
}
if (!isSSHEnabled) {
return <SSHDisabledText setShowTooltip={setShowTooltip} />;
}
if (!isOnline) {
return <IsOfflineText />;
}
return null;
};
return (
<FullTooltip
customOpen={showTooltip}
customOnOpenChange={setShowTooltip}
className={"w-full"}
side={side}
content={
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
{hasPermission ? (
<>
<div>
This peer is either offline or SSH access is not enabled.
</div>
<div>
Please enable SSH access for this peer in the dashboard and make
sure SSH is allowed in the NetBird Client under{" "}
<span className={"text-white"}>Settings &rarr; Allow SSH</span>.
</div>
<div>
Learn more about{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/ssh"}
target={"_blank"}
>
SSH <ExternalLinkIcon size={12} />
</InlineLink>
</div>
</>
) : (
<div>
You do not have permission to launch the SSH console. Please
contact your administrator.
</div>
)}
</div>
}
disabled={disabled}
content={tooltipContent()}
disabled={isOnline && isSSHEnabled && hasPermission}
>
{children}
</FullTooltip>
);
};
const NoPermissionText = () => {
return (
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
<div>
You do not have permission to launch the SSH console. Please contact
your administrator.
</div>
</div>
);
};
const IsOfflineText = () => {
return (
<div className={"max-w-[200px] text-xs"}>
<div>Connecting via SSH is only available when the peer is online.</div>
</div>
);
};
const SSHDisabledText = ({
setShowTooltip,
}: {
setShowTooltip: (show: boolean) => void;
}) => {
const { setSSHInstructionsModal } = usePeer();
return (
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
<div>
SSH Access is currently disabled for this peer. Please enable SSH access
for this peer and make sure SSH is allowed in the NetBird Client.
</div>
<div>
<InlineLink
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowTooltip(false);
setSSHInstructionsModal(true);
}}
href={"#"}
target={"_blank"}
>
Enable SSH Access <ArrowUpRightIcon size={12} />
</InlineLink>
</div>
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { useOidcAccessToken } from "@axa-fr/react-oidc";
import { useCallback, useRef, useState } from "react";
interface SSHConfig {
@@ -28,6 +29,7 @@ export const useSSH = (client: any) => {
const [config, setConfig] = useState<SSHConfig | null>(null);
const session = useRef<SSHConnection | null>(null);
const [error, setError] = useState("");
const { accessToken } = useOidcAccessToken();
const connect = useCallback(
async (config: SSHConfig): Promise<SSHStatus> => {

View File

@@ -4,7 +4,6 @@ import { getBrowserInfo } from "@utils/helpers";
import { generateKeypair } from "@utils/wireguard";
import { trim } from "lodash";
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { IronRDPInputHandler } from "@/modules/remote-access/rdp/ironrdp-input-handler";
import { IronRDPWASMBridge } from "@/modules/remote-access/rdp/ironrdp-wasm-bridge";
import { RDPCertificateHandler } from "@/modules/remote-access/rdp/rdp-certificate-handler";
import { installWebSocketProxy } from "@/modules/remote-access/rdp/websocket-proxy";
@@ -13,7 +12,7 @@ const config = loadConfig();
const WASM_CONFIG = {
SCRIPT_PATH: "/wasm_exec.js",
WASM_PATH: "https://pkgs.netbird.io/wasm/client",
WASM_PATH: config.wasmPath,
INIT_TIMEOUT: 10000,
RETRY_DELAY: 100,
} as const;
@@ -73,9 +72,8 @@ export const useNetBirdClient = () => {
const rdpComponents = useRef<{
bridge: IronRDPWASMBridge | null;
inputHandler: typeof IronRDPInputHandler | null;
certificateHandler: typeof RDPCertificateHandler | null;
}>({ bridge: null, inputHandler: null, certificateHandler: null });
}>({ bridge: null, certificateHandler: null });
const loadWASMRuntime = useCallback((): Promise<void> => {
if (document.querySelector(`script[src="${WASM_CONFIG.SCRIPT_PATH}"]`)) {
@@ -117,7 +115,6 @@ export const useNetBirdClient = () => {
installWebSocketProxy();
rdpComponents.current = {
bridge: new IronRDPWASMBridge(),
inputHandler: IronRDPInputHandler,
certificateHandler: RDPCertificateHandler,
};
}, []);
@@ -184,6 +181,7 @@ export const useNetBirdClient = () => {
});
await netBirdClient.current.start();
(window as any).netbird = netBirdClient.current;
netBirdStore.setState({ status: NetBirdStatus.CONNECTED });
return true;
} catch (error) {
@@ -206,11 +204,27 @@ export const useNetBirdClient = () => {
netBirdStore.setState({ status: NetBirdStatus.DISCONNECTED });
await netBirdClient.current.stop();
netBirdClient.current = null;
delete (window as any).netbird;
return Promise.resolve();
}, []);
const detectSSHServerType = useCallback(
async (host: string, port: number): Promise<boolean> => {
if (!netBirdClient.current?.detectSSHServerType) {
throw new Error("NetBird client not ready");
}
return netBirdClient.current.detectSSHServerType(host, port);
},
[],
);
const createSSHConnection = useCallback(
async (host: string, port: number, username: string): Promise<any> => {
async (
host: string,
port: number,
username: string,
jwtToken?: string,
): Promise<any> => {
if (!netBirdClient.current?.createSSHConnection) {
throw new Error("Go client not ready");
}
@@ -268,7 +282,7 @@ export const useNetBirdClient = () => {
{
name,
wg_pub_key: keyPairs.publicKey,
rules: rules ?? ["tcp/22", "tcp/3389", "tcp/44338"],
rules: rules ?? ["tcp/22022", "tcp/3389", "tcp/44338"],
},
`/${peerId}/temporary-access`,
);
@@ -289,15 +303,15 @@ export const useNetBirdClient = () => {
status,
wasmStatus,
error,
client: netBirdClient.current, // Expose the raw NetBird client
client: netBirdClient.current,
ironRDPBridge: rdpComponents.current.bridge,
ironRDPInputHandler: rdpComponents.current.inputHandler,
rdpCertificateHandler: rdpComponents.current.certificateHandler,
initialize,
initializeIronRDP,
connect,
connectTemporary,
disconnect,
detectSSHServerType,
createSSHConnection,
makeRequest,
proxyRequest,

View File

@@ -79,23 +79,13 @@ export default function MacOSTab({
</div>
<div className={"flex gap-4 mt-1 flex-wrap"}>
<Link
href={"https://pkgs.netbird.io/macos/amd64"}
href={"https://pkgs.netbird.io/macos/universal"}
passHref
target={"_blank"}
>
<Button variant={"primary"}>
<DownloadIcon size={14} />
Download for Intel
</Button>
</Link>
<Link
href={"https://pkgs.netbird.io/macos/arm64"}
passHref
target={"_blank"}
>
<Button variant={"outline"}>
<DownloadIcon size={14} />
Download for Apple Silicon
Download NetBird
</Button>
</Link>
</div>

View File

@@ -1,11 +1,12 @@
import Button from "@components/Button";
import Code from "@components/Code";
import { SelectDropdown } from "@components/select/SelectDropdown";
import Steps from "@components/Steps";
import TabsContentPadding, { TabsContent } from "@components/Tabs";
import { getNetBirdUpCommand, GRPC_API_ORIGIN } from "@utils/netbird";
import { DownloadIcon, PackageOpenIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
import React, { useState } from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import {
HostnameParameter,
@@ -24,6 +25,9 @@ export default function WindowsTab({
showSetupKeyInfo,
hostname,
}: Readonly<Props>) {
const [windowsUrl, setWindowsUrl] = useState(
"https://pkgs.netbird.io/windows/x64",
);
return (
<TabsContent value={String(OperatingSystem.WINDOWS)}>
<TabsContentPadding>
@@ -35,10 +39,35 @@ export default function WindowsTab({
<Steps.Step step={1}>
<p>Download and run Windows Installer</p>
<div className={"flex gap-4 mt-1"}>
<SelectDropdown
value={windowsUrl}
className={"w-[170px]"}
onChange={setWindowsUrl}
placeholder={"Select architecture"}
options={[
{
label: "64-Bit",
value: "https://pkgs.netbird.io/windows/x64",
},
{
label: "ARM64",
value: "https://pkgs.netbird.io/windows/arm64",
},
{
label: "64-Bit (MSI)",
value: "https://pkgs.netbird.io/windows/msi/x64",
},
{
label: "ARM64 (MSI)",
value: "https://pkgs.netbird.io/windows/msi/arm64",
},
]}
/>
<Link
href={"https://pkgs.netbird.io/windows/x64"}
href={windowsUrl}
passHref
target={"_blank"}
rel="noopener noreferrer"
>
<Button variant={"primary"}>
<DownloadIcon size={14} />

View File

@@ -17,6 +17,7 @@ interface Config {
hotjarTrackID?: number;
googleAnalyticsID?: string;
googleTagManagerID?: string;
wasmPath: string;
}
/**
@@ -66,6 +67,7 @@ const loadConfig = (): Config => {
hotjarTrackID: configJson?.hotjarTrackID || undefined,
googleAnalyticsID: configJson?.googleAnalyticsID || undefined,
googleTagManagerID: configJson?.googleTagManagerID || undefined,
wasmPath: configJson.wasmPath ?? "https://pkgs.netbird.io/wasm/client",
} as Config;
};