Compare commits

...
6 Commits
Author SHA1 Message Date
Eduard Gert 9a401733b3 Fix toggle for p2p policies (#501) 2025-10-31 13:21:23 +01:00
Eduard Gert 07b6895380 Sync SSH & RDP changes (#495) 2025-10-16 14:44:26 +02:00
Eduard Gert 9e2e38764e Add control center (#494)
* Add control center

* Update rdp doc link
2025-10-09 11:26:21 +02:00
Maycon Santos d9fb379abf Enable connect buttons (#493) 2025-10-06 16:23:00 -03:00
Eduard Gert 831673d0d6 Sync with cloud (#491)
implements a "Sync with cloud" functionality that includes various UI improvements, code refactoring, and component extractions. The changes focus on enhancing the user interface, improving code organization, and adding new features for remote access and activity tracking.

- Refactors inline components into reusable shared components
- Adds new activity tracking for group operations
- Updates remote access configuration and UI components
- Enhances styling and layout for better user experience
2025-10-03 14:37:11 +02:00
Maycon Santos bc4aac10aa Add browser client support (#490)
* Sync wasm rdp and ssh client

* sync package-lock

* remove msp ref

* add ephemeral info
2025-10-02 00:41:08 +02:00
127 changed files with 12021 additions and 683 deletions

No files matched your search

+28 -2
View File
@@ -28,6 +28,33 @@ jobs:
- run: echo '{}' > .local-config.json
- name: Download IronRDP release TS files
uses: robinraju/release-downloader@v1.7
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
fileName: "*.ts"
out-file-path: 'public/ironrdp-pkg'
- name: Download IronRDP release JS files
uses: robinraju/release-downloader@v1.7
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
fileName: "*.js"
out-file-path: 'public/ironrdp-pkg'
- name: Download IronRDP release WASM file
uses: robinraju/release-downloader@v1.7
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: netbirdio/IronRDP
latest: true
fileName: "ironrdp_web_bg.wasm"
out-file-path: 'public/ironrdp-pkg'
- name: Build
run: npm run build
-
@@ -44,7 +71,6 @@ jobs:
images: ${{ env.IMAGE_NAME }}
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.NB_DOCKER_USER }}
@@ -55,7 +81,7 @@ jobs:
with:
context: .
file: docker/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
push: true
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+5
View File
@@ -41,3 +41,8 @@ next-env.d.ts
.configs/.staging-config.json
.configs/.temp-config.json
.configs
/public/ironrdp-pkg/
/public/netbird.wasm
.idea
src/.local-config*
+1
View File
@@ -26,6 +26,7 @@ The dashboard makes it possible to:
- NextJS
- ReactJS
- Tailwind CSS
- [React Flow](https://reactflow.dev/) for the Control Center
- Auth0
- Nginx
- Docker
+2 -1
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"
}
+8
View File
@@ -3,6 +3,14 @@ server {
listen [::]:80 default_server;
root /usr/share/nginx/html;
location = /netbird.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location = /ironrdp-pkg/ironrdp_web_bg.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location / {
try_files $uri $uri.html $uri/ =404;
+2 -1
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"
+1
View File
@@ -101,6 +101,7 @@ http {
application/rss+xml
application/vnd.geo+json
application/vnd.ms-fontobject
application/wasm
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
+832 -9
View File
File diff suppressed because it is too large. Load diff
+8 -1
View File
@@ -14,6 +14,7 @@
},
"dependencies": {
"@axa-fr/react-oidc": "^7.22.18",
"@dagrejs/dagre": "^1.1.5",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -35,11 +36,15 @@
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
@@ -47,8 +52,10 @@
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"elkjs": "^0.10.0",
"eslint": "^8",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
@@ -58,7 +65,7 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.481.0",
"lucide-react": "^0.539.0",
"next": "^14.2.28",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
+575
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Control Center - ${globalMetaTitle}`,
};
export default BlankLayout;
File diff suppressed because it is too large. Load diff
+13 -1
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}>
+83 -32
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}
+35 -47
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";
@@ -33,16 +31,15 @@ import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
Barcode,
CalendarDays,
Cpu,
FlagIcon,
Globe,
History,
LockIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
TerminalSquare,
TimerResetIcon,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -65,6 +62,9 @@ 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";
export default function PeerPage() {
const queryParameter = useSearchParams();
@@ -80,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) {
@@ -104,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>
) : (
@@ -138,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(
@@ -158,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,
@@ -171,7 +168,6 @@ const PeerGeneralInformation = () => {
if (permission.peers.update) {
const updateRequest = update({
name: newName ?? name,
ssh,
loginExpiration,
inactivityExpiration,
});
@@ -187,7 +183,6 @@ const PeerGeneralInformation = () => {
mutate("/peers/" + peer.id);
mutate("/groups");
updateHasChangedRef([
ssh,
selectedGroups,
loginExpiration,
inactivityExpiration,
@@ -311,41 +306,17 @@ 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>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
{permission.groups.read && (
<div>
@@ -569,6 +540,23 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
/>
)}
{peer.created_at && (
<Card.ListItem
label={
<>
<CalendarDays size={16} />
Registered on
</>
}
value={
dayjs(peer.created_at).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.created_at) +
")"
}
/>
)}
<Card.ListItem
label={
<>
+9
View File
@@ -0,0 +1,9 @@
"use client";
import UsersProvider from "@/contexts/UsersProvider";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<UsersProvider>{children}</UsersProvider>
);
}
+213
View File
@@ -0,0 +1,213 @@
"use client";
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";
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
import {
RDPCredentials,
RDPStatus,
useRemoteDesktop,
} from "@/modules/remote-access/rdp/useRemoteDesktop";
import {
NetBirdStatus,
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
export default function RDPPage() {
const { peerId } = useRDPQueryParams();
const {
data: peer,
isLoading,
error,
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
return (
<div className={"w-screen h-screen overflow-hidden fixed inset-0"}>
{peerId && peer && !isLoading ? (
<RDPSession key={peer.id} peer={peer} />
) : (
<FullScreenLoading />
)}
</div>
);
}
type Props = {
peer: Peer;
};
function RDPSession({ peer }: Props) {
const client = useNetBirdClient();
const [isNetBirdConnecting, setIsNetBirdConnecting] = useState(false);
const rdp = useRemoteDesktop(client);
const [credentialsModal, setCredentialsModal] = useState(true);
const [credentials, setCredentials] = useState<RDPCredentials | null>(null);
const connected = useRef(false);
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,
});
};
const reset = useCallback(async () => {
setCredentials(null);
connected.current = false;
setCredentialsModal(true);
rdp.session?.disconnect();
await client.disconnect();
}, [client, rdp]);
/**
* Establishes a connection to the peer
*/
const connect = async (rdpCredentials: RDPCredentials) => {
if (!peer?.id) return;
if (client.status === NetBirdStatus.DISCONNECTED) {
try {
setCredentials(rdpCredentials);
setIsNetBirdConnecting(true);
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
setIsNetBirdConnecting(false);
} catch (error) {
sendErrorNotification(
"NetBird Connection Error",
(error as Error).message,
);
setIsNetBirdConnecting(false);
}
}
};
const startSession = useCallback(async () => {
if (!credentials) return;
try {
const result = await rdp.connect({
hostname: peer.ip,
port: credentials.port,
username: credentials.username,
password: credentials.password,
domain: credentials.domain,
width: window.innerWidth,
height: window.innerHeight,
});
if (result === RDPStatus.CONNECTED) {
connected.current = true;
} else {
}
} catch (error) {
sendErrorNotification("RDP Connection Error", (error as Error).message);
setCredentialsModal(true);
await reset();
}
}, [credentials, peer.ip, rdp, reset]);
/**
* Establish RDP session when NetBird connection is ready
*/
useEffect(() => {
if (
client.status === NetBirdStatus.CONNECTED &&
rdp.status === RDPStatus.DISCONNECTED &&
credentials &&
!connected.current &&
!isNetBirdConnecting
) {
startSession().catch(console.error);
}
}, [
client.status,
credentials,
peer.ip,
rdp,
startSession,
isNetBirdConnecting,
]);
/**
* Display notifications for RDP and NetBird client errors
*/
useEffect(() => {
if (rdp.error) {
sendErrorNotification("RDP Error", rdp.error);
}
if (client.error) {
sendErrorNotification("NetBird Client Error", client.error);
}
}, [rdp, client]);
/**
* Close credentials modal when RDP is connected
*/
useEffect(() => {
if (rdp.status === RDPStatus.CONNECTED) {
setCredentialsModal(false);
}
}, [rdp.status]);
const isLoading =
client.status === NetBirdStatus.CONNECTING ||
rdp.status === RDPStatus.CONNECTING ||
rdp.isResizing ||
isNetBirdConnecting;
return (
<>
{/* Credentials Modal */}
<RDPCredentialsModal
open={credentialsModal}
peer={peer}
onConnect={connect}
loading={isLoading}
/>
{/* Certificate Modal */}
<RDPCertificateModal
open={!!rdp.pendingCertificate}
certificateInfo={rdp.pendingCertificate}
onAccept={rdp.acceptCertificatePrompt}
onReject={async () => {
rdp.rejectCertificatePrompt();
await reset();
}}
/>
{rdp.isResizing && (
<div
className={
"fixed w-screen h-screen z-50 backdrop-blur bg-black/50 flex items-center justify-center"
}
>
<Loader2Icon size={20} className={"animate-spin"} />
</div>
)}
{/* RDP Canvas */}
<canvas
ref={rdp.canvasRef}
className={cn(
rdp.status === RDPStatus.CONNECTED ? "block" : "hidden",
"w-full h-full select-none bg-nb-gray-950",
)}
style={{ imageRendering: "pixelated" }}
/>
</>
);
}
+236
View File
@@ -0,0 +1,236 @@
"use client";
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";
import { Terminal } from "@/modules/remote-access/ssh/Terminal";
import { SSHStatus, useSSH } from "@/modules/remote-access/ssh/useSSH";
import { useSSHQueryParams } from "@/modules/remote-access/ssh/useSSHQueryParams";
import {
NetBirdStatus,
useNetBirdClient,
} from "@/modules/remote-access/useNetBirdClient";
export default function SSHPage() {
const { peerId, username, port } = useSSHQueryParams();
const {
data: peer,
isLoading,
error,
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
if (error) {
return (
<div className={"w-screen h-screen overflow-hidden"}>
<ErrorMessage
error={{
message:
"This peer may have been deleted, or you may not have permission to view it.",
code: error.code,
}}
/>
</div>
);
}
return (
<div className={"w-screen h-screen overflow-hidden"}>
{peerId && peer && !isLoading && username && port ? (
<SSHTerminal
key={peer.id}
peer={peer}
username={username}
port={port}
/>
) : (
<LoadingMessage message={"Starting ssh session..."} />
)}
</div>
);
}
type Props = {
username: string;
port: string;
peer: Peer;
};
function SSHTerminal({ username, port, peer }: Props) {
const client = useNetBirdClient();
const connected = useRef(false);
const sshConnectedOnce = useRef(false);
const {
connect: ssh,
disconnect,
status,
session,
error: sshError,
} = useSSH(client);
const isSSHConnecting = status === SSHStatus.CONNECTING;
const isSSHConnected = status === SSHStatus.CONNECTED;
const isSSHDisconnected = status === SSHStatus.DISCONNECTED;
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
useEffect(() => {
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
}, [username, peer, client]);
const handleReconnect = async () => {
if (!peer?.id) return;
if (isSSHConnected || isSSHConnecting) return;
connected.current = false;
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const rules = [`tcp/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
await ssh({
hostname: peer.ip,
port: Number(port),
username,
});
} catch (error) {
console.error("Reconnection failed:", error);
}
};
useEffect(() => {
if (isSSHConnected || isSSHConnecting) return;
if (isClientConnecting || client.status === NetBirdStatus.CONNECTED) return;
const connect = async () => {
if (!peer.id) return;
if (connected.current) return;
connected.current = true;
try {
const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port;
const rules = [`tcp/${aclPort}`];
await client?.connectTemporary(peer.id, rules);
const res = await ssh({
hostname: peer.ip,
port: Number(port),
username,
});
if (res === SSHStatus.CONNECTED) {
sshConnectedOnce.current = true;
}
} catch (error) {
console.error("Connection failed:", error);
}
};
if (isClientDisconnected) connect().catch(console.error);
}, [
isClientDisconnected,
isSSHConnected,
isSSHConnecting,
isClientConnecting,
peer.id,
port,
ssh,
username,
client.connectTemporary,
client.status,
]);
if (client.error) {
return <ErrorMessage error={{ message: client.error, code: 0 }} />;
}
if (sshError) {
return <ErrorMessage error={{ message: sshError, code: 0 }} />;
}
if (isSSHDisconnected && sshConnectedOnce.current) {
return (
<DisconnectedMessage
username={username}
peerIp={peer.ip}
onReconnect={handleReconnect}
/>
);
}
return (
<>
{session && <Terminal session={session} onClose={disconnect} />}
{!isSSHConnected && (
<LoadingMessage message={`Connecting to ${username}@${peer.ip}...`} />
)}
</>
);
}
type MessageProps = {
message?: string;
error?: ErrorResponse;
};
const LoadingMessage = ({ message }: MessageProps) => {
return (
<div
className={
"w-full h-full flex items-center justify-center flex-col text-center"
}
>
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
<Loader2Icon size={16} className={"animate-spin shrink-0"} />
{message}
</div>
</div>
);
};
const ErrorMessage = ({ error }: MessageProps) => {
return (
<div
className={
"w-full h-full flex items-center justify-center flex-col text-center"
}
>
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
<CircleXIcon size={16} className={"shrink-0 text-red-500"} />
{error?.message}
</div>
</div>
);
};
type DisconnectedMessageProps = {
username: string;
peerIp: string;
onReconnect: () => void;
};
const DisconnectedMessage = ({
username,
peerIp,
onReconnect,
}: DisconnectedMessageProps) => {
return (
<div
className={
"w-full h-full flex items-center justify-center flex-col text-center gap-4"
}
>
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
<InfoIcon size={16} className={"shrink-0 text-nb-gray-200"} />
Disconnected from {username}@{peerIp}
<button
className={
"underline-offset-4 items-center transition-all duration-200 inline-flex texts-inherit gap-1 text-netbird hover:underline font-normal"
}
onClick={onReconnect}
>
Reconnect
</button>
</div>
</div>
);
};
+17
View File
@@ -157,3 +157,20 @@ p {
.animate-bg-scroll-faster {
animation: bg-scroll 1.8s linear infinite;
}
/**
* Terminal (xterm)
*/
.xterm {
@apply m-0 p-1 box-border h-full w-full;
}
.xterm-viewport {
@apply m-0 p-0 box-border;
}
/* Control Center */
.react-flow__node-groupNode .selected{
@apply border-netbird;
}
+2 -1
View File
@@ -35,6 +35,7 @@ export default function NotFound() {
}
const Redirect = ({ url, queryParams }: Props) => {
useRedirect("/peers" + (queryParams && `?${queryParams}`));
const params = queryParams && `?${queryParams}`;
useRedirect(url == "/" ? `/peers${params}` : `${url}${params}`);
return <FullScreenLoading />;
};
Loaded 20 of 127 files, more files were not shown because too many files have changed in this diff. Show more