Add browser client support (#490)

* Sync wasm rdp and ssh client

* sync package-lock

* remove msp ref

* add ephemeral info
This commit is contained in:
Maycon Santos
2025-10-01 19:41:08 -03:00
committed by GitHub
parent 38e14a6c64
commit bc4aac10aa
67 changed files with 6239 additions and 333 deletions

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
-

5
.gitignore vendored
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*

View File

@@ -3,6 +3,10 @@ server {
listen [::]:80 default_server;
root /usr/share/nginx/html;
location = /netbird.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location / {
try_files $uri $uri.html $uri/ =404;

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

17
package-lock.json generated
View File

@@ -35,6 +35,8 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
@@ -2894,6 +2896,21 @@
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
"integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ=="
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",

View File

@@ -40,6 +40,8 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",

575
public/wasm_exec.js Normal file
View File

@@ -0,0 +1,575 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.path) {
globalThis.path = {
resolve(...pathSegments) {
return pathSegments.join("/");
}
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const testCallExport = (a, b) => {
this._inst.exports.testExport0();
return this._inst.exports.testExport(a, b);
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
callExport: testCallExport,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();

View File

@@ -33,6 +33,7 @@ import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
Barcode,
CalendarDays,
Cpu,
FlagIcon,
Globe,
@@ -65,6 +66,8 @@ 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 { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
export default function PeerPage() {
const queryParameter = useSearchParams();
@@ -347,6 +350,16 @@ const PeerGeneralInformation = () => {
/>
</FullTooltip>
{/* 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>
<Label>Assigned Groups</Label>

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>
);
}

View File

@@ -0,0 +1,212 @@
"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 { 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";
import { cn } from "@utils/helpers";
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"}>
{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`;
}, []);
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,
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" }}
/>
</>
);
}

View File

@@ -0,0 +1,233 @@
"use client";
import { PageNotFound } from "@components/ui/PageNotFound";
import useFetchApi, { ErrorResponse } from "@utils/api";
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 rules = [`tcp/${port}`];
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 rules = [`tcp/${port}`];
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>
);
};

View File

@@ -157,3 +157,14 @@ 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;
}

View File

@@ -58,6 +58,8 @@ export default function OIDCProvider({ children }: Props) {
"utm_content",
"utm_campaign",
"hs_id",
"user",
"port",
];
try {

View File

@@ -18,7 +18,7 @@ export const buttonVariants = cva(
"relative",
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm",
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
"disabled:opacity-20 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
"disabled:opacity-40 disabled:cursor-not-allowed disabled:dark:text-nb-gray-300 dark:ring-offset-neutral-950/50",
],
{
variants: {

View File

@@ -26,7 +26,7 @@ export const calloutVariants = cva(
export const Callout = ({
children,
icon = <InfoIcon size={14} className={"shrink-0 relative top-[2px]"} />,
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
className,
variant = "default",
}: Props) => {

View File

@@ -43,6 +43,13 @@ import type { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
import { User } from "@/interfaces/User";
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
const groupsSearchPredicate = (item: Group, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item?.id?.toLowerCase().includes(lowerCaseQuery) ?? false;
};
interface MultiSelectProps {
values: Group[];
@@ -60,6 +67,7 @@ interface MultiSelectProps {
dataCy?: string;
showResourceCounter?: boolean;
showResources?: boolean;
showPeers?: boolean;
resource?: PolicyRuleResource;
onResourceChange?: (resource?: PolicyRuleResource) => void;
placeholder?: string;
@@ -67,6 +75,7 @@ interface MultiSelectProps {
align?: "start" | "end";
side?: "top" | "bottom";
users?: User[];
placeholderForSearch?: string;
}
export function PeerGroupSelector({
onChange,
@@ -84,6 +93,7 @@ export function PeerGroupSelector({
dataCy = "group-selector-dropdown",
showResourceCounter = true,
showResources = false,
showPeers = false,
resource,
onResourceChange,
placeholder = "Add or select group(s)...",
@@ -91,16 +101,35 @@ export function PeerGroupSelector({
align = "start",
side = "bottom",
users,
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
}: Readonly<MultiSelectProps>) {
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
useGroups();
const searchRef = React.useRef<HTMLInputElement>(null);
const [inputRef, { width }] = useElementSize<
HTMLButtonElement | HTMLSpanElement
>();
const [search, setSearch] = useState("");
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
"/networks/resources",
const [open, setOpen] = useState(false);
const sortedDropdownOptions = useSortedDropdownOptions(
dropdownOptions,
values,
open,
);
const [filteredGroups, search, setSearch] = useSearch(
sortedDropdownOptions,
groupsSearchPredicate,
{ filter: true, debounce: 150 },
);
// Update dropdown options when groups change
@@ -189,16 +218,6 @@ export function PeerGroupSelector({
return isSearching && groupDoesNotExist && !isAllGroup;
}, [search, dropdownOptions]);
const [open, setOpen] = useState(false);
const folderIcon = useMemo(() => {
return <FolderGit2 size={12} className={"shrink-0"} />;
}, []);
const peerIcon = useMemo(() => {
return <MonitorSmartphoneIcon size={14} className={"shrink-0"} />;
}, []);
const [slice, setSlice] = useState(10);
const [tab, setTab] = useState("groups");
@@ -219,12 +238,6 @@ export function PeerGroupSelector({
onChange(union);
};
const sortedDropdownOptions = useSortedDropdownOptions(
dropdownOptions,
values,
open,
);
// Reset the search input when switching tabs
useEffect(() => {
setSearch("");
@@ -233,10 +246,12 @@ export function PeerGroupSelector({
}, 0);
}, [tab]);
const searchPlaceholder =
tab === "groups"
? 'Search groups or add new group by pressing "Enter"...'
: "Search resource...";
const searchPlaceholder = useMemo(() => {
if (tab === "groups") return placeholderForSearch;
if (tab === "resources") return "Search resource...";
if (tab === "peers") return "Search peer...";
return "Search...";
}, [tab, placeholderForSearch]);
const selectResource = (resource?: NetworkResource) => {
onResourceChange?.(
@@ -250,6 +265,15 @@ export function PeerGroupSelector({
onChange([]);
};
const selectPeer = (peer?: Peer) => {
if (!peer?.id) return;
onResourceChange?.({
id: peer.id,
type: "peer",
});
onChange([]);
};
return (
<Popover
open={open}
@@ -288,6 +312,7 @@ export function PeerGroupSelector({
<ResourceBadge
className={"py-[3px]"}
resource={resources?.find((r) => r.id === resource.id)}
peer={peers?.find((p) => p.id === resource.id)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -364,16 +389,7 @@ export function PeerGroupSelector({
side={side}
sideOffset={10}
>
<Command
className={"w-full flex"}
loop
filter={(value, search) => {
const formatValue = trim(value.toLowerCase());
const formatSearch = trim(search.toLowerCase());
if (formatValue.includes(formatSearch)) return 1;
return 0;
}}
>
<Command className={"w-full flex"} loop shouldFilter={false}>
<CommandList className={"w-full"}>
<div className={"relative"}>
<CommandInput
@@ -414,13 +430,17 @@ export function PeerGroupSelector({
</div>
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
{showResources && <TabTriggers searchRef={searchRef} />}
<TabTriggers
searchRef={searchRef}
showPeers={showPeers}
showResources={showResources}
/>
<TabsContent value={"groups"} className={"p-0 my-0"}>
<CommandGroup>
<ScrollArea
className={cn(
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
sortedDropdownOptions.length == 0 && !search && "py-0",
filteredGroups.length == 0 && !search && "py-0",
)}
>
{searchedGroupNotFound && (
@@ -433,8 +453,8 @@ export function PeerGroupSelector({
value={search}
onClick={(e) => e.preventDefault()}
>
<Badge variant={"gray-ghost"}>
{folderIcon}
<Badge variant={"gray-ghost"} className={"h-7"}>
<FolderGit2 size={12} className={"shrink-0"} />
{search}
</Badge>
<div
@@ -448,7 +468,7 @@ export function PeerGroupSelector({
</CommandItem>
)}
{sortedDropdownOptions.slice(0, slice).map((option) => {
{filteredGroups.slice(0, slice).map((option) => {
const isSelected =
values.find((group) => group.name == option.name) !=
undefined;
@@ -490,7 +510,11 @@ export function PeerGroupSelector({
onClick={(e) => e.preventDefault()}
>
<div className={"flex items-center gap-2"}>
<GroupBadge group={option} showNewBadge={true} />
<GroupBadge
group={option}
showNewBadge={true}
className={"h-7"}
/>
</div>
<div className={"flex items-center gap-5"}>
@@ -509,7 +533,10 @@ export function PeerGroupSelector({
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{peerIcon}
<MonitorSmartphoneIcon
size={14}
className={"shrink-0"}
/>
{peerCount} Peer(s)
</div>
) : (
@@ -535,12 +562,23 @@ export function PeerGroupSelector({
<ResourcesList
search={search}
resources={resources}
isLoading={isLoading}
isLoading={isResourcesLoading}
value={resource}
onChange={selectResource}
/>
</TabsContent>
)}
{showPeers && (
<TabsContent value={"peers"} className={"p-0 my-0"}>
<PeersList
search={search}
peers={peers}
isLoading={isPeersLoading}
value={resource}
onChange={selectPeer}
/>
</TabsContent>
)}
</Tabs>
</CommandList>
</Command>
@@ -551,9 +589,14 @@ export function PeerGroupSelector({
const TabTriggers = ({
searchRef,
showResources = false,
showPeers = false,
}: {
searchRef: React.MutableRefObject<HTMLInputElement | null>;
showResources?: boolean;
showPeers?: boolean;
}) => {
if (!showResources && !showPeers) return null;
return (
<TabsList justify={"start"} className={"px-3"}>
<TabsTrigger
@@ -569,19 +612,38 @@ const TabTriggers = ({
/>
Groups
</TabsTrigger>
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resource
</TabsTrigger>
{showResources && (
<TabsTrigger
value={"resources"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<Layers3Icon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Resources
</TabsTrigger>
)}
{showPeers && (
<TabsTrigger
value={"peers"}
className={"text-[.8rem] font-normal"}
onClick={() => searchRef.current?.focus()}
>
<MonitorSmartphoneIcon
className={
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
}
size={14}
/>
Peers
</TabsTrigger>
)}
</TabsList>
);
};
@@ -700,7 +762,7 @@ const ResourcesList = ({
useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap")}
className={cn("transition-all group whitespace-nowrap h-7")}
onClick={(e) => {
e.preventDefault();
}}
@@ -736,3 +798,107 @@ const ResourcesList = ({
</Radio>
);
};
const peersSearchPredicate = (item: Peer, query: string) => {
const lowerCaseQuery = query.toLowerCase();
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
return item.ip.toLowerCase().includes(lowerCaseQuery);
};
const PeersList = ({
search,
peers,
isLoading,
value,
onChange,
}: {
search: string;
peers?: Peer[];
isLoading: boolean;
value?: PolicyRuleResource;
onChange: (peer: Peer) => void;
}) => {
const [filteredItems, _, setSearch] = useSearch(
peers || [],
peersSearchPredicate,
{ filter: true, debounce: 150 },
);
useEffect(() => {
setSearch(search);
}, [search, setSearch]);
if (isLoading) {
return (
<div className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}>
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
<Skeleton height={42} className={"rounded-md"} />
</div>
);
}
if (search != "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no peers matching your search. Please try a different search
term.
</DropdownInfoText>
);
}
if (search == "" && filteredItems.length == 0) {
return (
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
There are no peers available yet. <br />
Go to <InlineLink href={"/peers"}>Peers</InlineLink> to add some peers.
</DropdownInfoText>
);
}
return (
<Radio defaultValue={value?.id} name={"peer"} value={value?.id}>
<VirtualScrollAreaList
items={filteredItems}
onSelect={onChange}
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
renderItem={(res) => {
if (!res?.id) return;
return (
<Fragment key={res.id}>
<div className={"flex items-center gap-2"}>
<Badge
useHover={true}
data-cy={"group-badge"}
variant={"gray-ghost"}
className={cn(
"transition-all group whitespace-nowrap h-7 px-2",
)}
onClick={(e) => {
e.preventDefault();
}}
>
<PeerOperatingSystemIcon os={res.os} />
<TextWithTooltip text={res?.name || ""} maxChars={20} />
</Badge>
</div>
<div className={"flex items-center gap-5"}>
<div
className={
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
{res.ip}
<RadioItem value={res.id} />
</div>
</div>
</Fragment>
);
}}
/>
</Radio>
);
};

View File

@@ -4,7 +4,6 @@ import FullTooltip from "@components/FullTooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { useSearch } from "@hooks/useSearch";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
@@ -16,7 +15,7 @@ import { memo, useEffect, useState } from "react";
import { useElementSize } from "@/hooks/useElementSize";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { OSLogo } from "@/modules/peers/PeerOSCell";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
const MapPinIcon = memo(() => <MapPin size={12} />);
MapPinIcon.displayName = "MapPinIcon";
@@ -182,7 +181,6 @@ export function PeerSelector({
togglePeer(item);
}}
renderItem={(option) => {
const os = getOperatingSystem(option.os);
const isSupported = isRoutingPeerSupported(
option.version,
option.os,
@@ -210,19 +208,10 @@ export function PeerSelector({
: "text-nb-gray-300",
)}
>
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
os === OperatingSystem.WINDOWS && "p-[2.5px]",
os === OperatingSystem.APPLE && "p-[2.7px]",
os === OperatingSystem.FREEBSD && "p-[1.5px]",
!isSupported && "opacity-50",
)}
>
<OSLogo os={option.os} />
</div>
<PeerOperatingSystemIcon
os={option.os}
className={isSupported ? "" : "opacity-50"}
/>
<div className={cn(!isSupported && "opacity-50")}>
<TextWithTooltip
text={option.name}

View File

@@ -14,6 +14,7 @@ type Props<T extends { id?: string }> = {
renderHeading?: (item: T) => React.ReactNode;
renderBeforeItem?: (item: T) => React.ReactNode;
itemClassName?: string;
itemClassNameWithItem?: (item: T) => string;
itemWrapperClassName?: string;
scrollAreaClassName?: string;
maxHeight?: number;
@@ -21,6 +22,7 @@ type Props<T extends { id?: string }> = {
estimatedHeadingHeight?: number;
heightAdjustment?: number;
groupKey?: (item: T) => string | undefined;
itemKey?: (item: T) => string;
};
export function VirtualScrollAreaList<T extends { id?: string }>({
@@ -30,6 +32,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
renderBeforeItem,
renderHeading,
itemClassName,
itemClassNameWithItem,
itemWrapperClassName,
scrollAreaClassName,
maxHeight,
@@ -37,6 +40,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
estimatedHeadingHeight = 16,
heightAdjustment = 8,
groupKey,
itemKey,
}: Readonly<Props<T>>) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">(
@@ -159,10 +163,14 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
setSelected(index);
}
}}
id={option.id}
id={itemKey ? itemKey(option) : option?.id}
onClick={() => onClick(option)}
ariaSelected={selected === index}
itemClassName={itemClassName}
itemClassName={
itemClassNameWithItem
? itemClassNameWithItem(option)
: itemClassName
}
className={itemWrapperClassName}
isLast={index === items.length - 1}
>

View File

@@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) {
}}
>
<Button
className={"h-[42px]"}
className={"h-[44px]"}
variant={"secondary"}
disabled={isDisabled == true ? true : disabled}
>

View File

@@ -36,7 +36,7 @@ export default function DataTableResetFilterButton<TData>({
}}
>
<Button
className={"h-[42px]"}
className={"h-[44px]"}
variant={"secondary"}
onClick={onClick}
>

View File

@@ -3,7 +3,7 @@ import { memo } from "react";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
const MemoizedNetBirdIcon = () => {
return <NetBirdIcon size={16} />;
return <NetBirdIcon size={14} />;
};
export default memo(MemoizedNetBirdIcon);

View File

@@ -4,9 +4,13 @@ import { cn } from "@utils/helpers";
import { GlobeIcon, NetworkIcon, WorkflowIcon, XIcon } from "lucide-react";
import * as React from "react";
import { NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
type Props = {
resource?: NetworkResource;
peer?: Peer;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
showX?: boolean;
children?: React.ReactNode;
@@ -15,35 +19,44 @@ type Props = {
export default function ResourceBadge({
onClick,
resource,
peer,
showX = false,
children,
className,
}: Readonly<Props>) {
if (!resource) return;
if (!resource && !peer) return;
const isPeer = !!peer;
const key = resource ? resource.id || resource?.name : peer?.id || peer?.name;
return (
<Badge
key={resource.id || resource?.name}
key={key}
useHover={true}
data-cy={"resource-badge"}
variant={"gray-ghost"}
className={cn("transition-all group whitespace-nowrap", className)}
className={cn(
"transition-all group whitespace-nowrap",
className,
isPeer && "px-2",
)}
onClick={(e) => {
e.preventDefault();
onClick?.(e);
}}
>
{resource.type === "host" && (
<WorkflowIcon size={12} className={"shrink-0"} />
)}
{resource.type === "domain" && (
<GlobeIcon size={12} className={"shrink-0"} />
)}
{resource.type === "subnet" && (
<NetworkIcon size={12} className={"shrink-0"} />
{isPeer ? (
<>
<PeerOperatingSystemIcon os={peer?.os} />
<TruncatedText text={peer?.name || ""} maxChars={20} />
</>
) : (
<>
<ResourceIcon type={resource?.type || ""} />
<TruncatedText text={resource?.name || ""} maxChars={20} />
</>
)}
<TruncatedText text={resource?.name || ""} maxChars={20} />
{children}
{showX && (
<XIcon
@@ -56,3 +69,16 @@ export default function ResourceBadge({
</Badge>
);
}
const ResourceIcon = ({ type }: { type: string }) => {
switch (type) {
case "host":
return <WorkflowIcon size={12} className={"shrink-0"} />;
case "domain":
return <GlobeIcon size={12} className={"shrink-0"} />;
case "subnet":
return <NetworkIcon size={12} className={"shrink-0"} />;
default:
return null;
}
};

View File

@@ -24,7 +24,7 @@ export default function TextWithTooltip({
<FullTooltip
disabled={charCount <= maxChars || hideTooltip}
interactive={false}
className={"truncate w-full min-w-0"}
className={"truncate w-auto min-w-0"}
skipDelayDuration={350}
delayDuration={200}
content={

View File

@@ -97,11 +97,22 @@ export default function PeerProvider({ children, peer }: Props) {
const openSSHDialog = async (): Promise<boolean> => {
return await confirm({
title: `Enable SSH Server for ${peer.name}?`,
description:
"Experimental feature. Enabling this option allows remote SSH access to this machine from other connected network participants.",
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",
});
};

View File

@@ -15,6 +15,7 @@ export interface Peer {
user_id?: string;
user?: User;
ui_version?: string;
kernel_version?: string;
dns_label: string;
extra_dns_labels?: string[];
last_login: Date;
@@ -26,4 +27,5 @@ export interface Peer {
country_code: string;
connection_ip: string;
serial_number: string;
ephemeral: boolean;
}

View File

@@ -34,7 +34,7 @@ export interface PortRange {
export interface PolicyRuleResource {
id: string;
type: "domain" | "host" | "subnet" | undefined;
type?: "domain" | "host" | "subnet" | "peer";
}
export type Protocol = "all" | "tcp" | "udp" | "icmp";

View File

@@ -31,7 +31,7 @@ export default function NavbarWithDropdown() {
<AnnouncementBanner />
<div
className={cn(
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray/50 backdrop-blur-lg sm:px-6",
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
"border-b dark:border-zinc-700/40 px-3 md:px-4 w-full",
"flex justify-between items-center transition-all",
)}

View File

@@ -156,6 +156,8 @@ export function AccessControlModalContent({
submit,
isPostureChecksLoading,
getPolicyData,
sourceResource,
setSourceResource,
destinationResource,
setDestinationResource,
portRanges,
@@ -176,15 +178,17 @@ export function AccessControlModalContent({
return "policy";
});
const continuePostureChecksDisabled = useMemo(() => {
if (sourceGroups.length > 0 && destinationResource) return false;
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
}, [sourceGroups, destinationGroups, destinationResource]);
const canContinueToPostureChecks = useMemo(() => {
const hasSource = sourceGroups.length > 0 || !!sourceResource;
const hasDestination =
destinationGroups.length > 0 || !!destinationResource;
return hasSource && hasDestination;
}, [sourceGroups, destinationGroups, destinationResource, sourceResource]);
const submitDisabled = useMemo(() => {
if (name.length == 0) return true;
if (continuePostureChecksDisabled) return true;
}, [name, continuePostureChecksDisabled]);
if (!canContinueToPostureChecks) return true;
}, [name, canContinueToPostureChecks]);
const handleProtocolChange = (p: Protocol) => {
setProtocol(p);
@@ -220,11 +224,8 @@ export function AccessControlModalContent({
<ArrowRightLeft size={16} />
Policy
</TabsTrigger>
<PostureCheckTabTrigger disabled={continuePostureChecksDisabled} />
<TabsTrigger
value={"general"}
disabled={continuePostureChecksDisabled}
>
<PostureCheckTabTrigger disabled={!canContinueToPostureChecks} />
<TabsTrigger value={"general"} disabled={!canContinueToPostureChecks}>
<Text
size={16}
className={
@@ -283,14 +284,19 @@ export function AccessControlModalContent({
</Label>
<PeerGroupSelector
dataCy={"source-group-selector"}
popoverWidth={500}
placeholder={"Select source(s)..."}
showRoutes={true}
showResources={false}
showPeers={true}
showResourceCounter={true}
showPeerCount={allowEditPeers}
disableInlineRemoveGroup={false}
popoverWidth={500}
showRoutes={false}
onChange={setSourceGroups}
values={sourceGroups}
onChange={setSourceGroups}
resource={sourceResource}
onResourceChange={setSourceResource}
saveGroupAssignments={useSave}
showResourceCounter={false}
disabled={
!permission.policies.update || !permission.policies.create
}
@@ -310,17 +316,19 @@ export function AccessControlModalContent({
</Label>
<PeerGroupSelector
dataCy={"destination-group-selector"}
popoverWidth={500}
placeholder={"Select destination(s)..."}
showRoutes={true}
showResources={true}
showPeers={true}
showResourceCounter={true}
showPeerCount={allowEditPeers}
disableInlineRemoveGroup={false}
popoverWidth={500}
onChange={setDestinationGroups}
values={destinationGroups}
saveGroupAssignments={useSave}
onChange={setDestinationGroups}
resource={destinationResource}
onResourceChange={setDestinationResource}
showResources={true}
placeholder={"Select destination(s)..."}
saveGroupAssignments={useSave}
disabled={
!permission.policies.update || !permission.policies.create
}
@@ -453,35 +461,36 @@ export function AccessControlModalContent({
{!policy ? (
<>
{tab == "policy" && (
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<>
<ModalClose asChild={true}>
<Button variant={"secondary"}>Cancel</Button>
</ModalClose>
<Button
variant={"primary"}
onClick={() => setTab("posture_checks")}
disabled={!canContinueToPostureChecks}
>
Continue
</Button>
</>
)}
{tab == "posture_checks" && (
<Button variant={"secondary"} onClick={() => setTab("policy")}>
Back
</Button>
)}
{tab == "policy" && (
<Button
variant={"primary"}
onClick={() => setTab("posture_checks")}
disabled={continuePostureChecksDisabled}
>
Continue
</Button>
)}
{tab == "posture_checks" && (
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={continuePostureChecksDisabled}
>
Continue
</Button>
<>
<Button
variant={"secondary"}
onClick={() => setTab("policy")}
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => setTab("general")}
disabled={!canContinueToPostureChecks}
>
Continue
</Button>
</>
)}
{tab == "general" && (

View File

@@ -1,11 +1,9 @@
import MultipleGroups from "@components/ui/MultipleGroups";
import ResourceBadge from "@components/ui/ResourceBadge";
import useFetchApi from "@utils/api";
import React, { useMemo } from "react";
import Skeleton from "react-loading-skeleton";
import { Group } from "@/interfaces/Group";
import { NetworkResource } from "@/interfaces/Network";
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
import { Policy } from "@/interfaces/Policy";
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
policy: Policy;
@@ -20,30 +18,13 @@ export default function AccessControlDestinationsCell({
if (firstRule?.destinationResource) {
return (
<AccessControlDestinationResourceCell
resource={firstRule.destinationResource}
/>
<AccessControlResourceCell resource={firstRule.destinationResource} />
);
}
return firstRule ? (
<MultipleGroups groups={firstRule.destinations as Group[]} />
) : null;
) : (
<EmptyRow />
);
}
const AccessControlDestinationResourceCell = ({
resource,
}: {
resource: PolicyRuleResource;
}) => {
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
"/networks/resources",
);
if (isLoading) return <Skeleton height={35} width={"50%"} />;
return (
<div className={"flex"}>
<ResourceBadge resource={resources?.find((r) => r.id === resource.id)} />
</div>
);
};

View File

@@ -0,0 +1,34 @@
import ResourceBadge from "@components/ui/ResourceBadge";
import useFetchApi from "@utils/api";
import * as React from "react";
import Skeleton from "react-loading-skeleton";
import { NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import { PolicyRuleResource } from "@/interfaces/Policy";
type Props = {
resource?: PolicyRuleResource;
};
export const AccessControlResourceCell = ({ resource }: Props) => {
const { data: resources, isLoading: isLoadingResources } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const { data: peers, isLoading: isLoadingPeers } =
useFetchApi<Peer[]>("/peers");
const isPeer = resource?.type === "peer";
const peer = peers?.find((p) => p.id === resource?.id);
if ((isPeer && isLoadingPeers) || (!isPeer && isLoadingResources))
return <Skeleton height={35} width={"50%"} />;
return (
<div className={"flex"}>
<ResourceBadge
resource={resources?.find((r) => r.id === resource?.id)}
peer={peer}
/>
</div>
);
};

View File

@@ -2,6 +2,8 @@ import MultipleGroups from "@components/ui/MultipleGroups";
import React, { useMemo } from "react";
import { Group } from "@/interfaces/Group";
import { Policy } from "@/interfaces/Policy";
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
type Props = {
policy: Policy;
@@ -12,7 +14,13 @@ export default function AccessControlSourcesCell({ policy }: Props) {
return undefined;
}, [policy]);
if (firstRule?.sourceResource) {
return <AccessControlResourceCell resource={firstRule.sourceResource} />;
}
return firstRule ? (
<MultipleGroups groups={firstRule.sources as Group[]} />
) : null;
) : (
<EmptyRow />
);
}

View File

@@ -9,9 +9,9 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
import GetStartedTest from "@components/ui/GetStartedTest";
import type { ColumnDef, SortingState } from "@tanstack/react-table";
import { removeAllSpaces } from "@utils/helpers";
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
import { ClockFadingIcon, ExternalLinkIcon, PlusCircle } from "lucide-react";
import { usePathname, useSearchParams } from "next/navigation";
import React, { useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
@@ -29,6 +29,7 @@ import AccessControlPortsCell from "@/modules/access-control/table/AccessControl
import AccessControlPostureCheckCell from "@/modules/access-control/table/AccessControlPostureCheckCell";
import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell";
import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell";
import FullTooltip from "@components/FullTooltip";
type Props = {
policies?: Policy[];
@@ -200,6 +201,41 @@ export default function AccessControlTable({
const [currentRow, setCurrentRow] = useState<Policy>();
const [currentCellClicked, setCurrentCellClicked] = useState("");
const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false);
const withTemporaryPolicies = useCallback(
(condition: boolean) =>
policies?.filter((policy) =>
condition
? policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
: !(
policy?.name?.startsWith("Temporary") &&
policy?.name?.endsWith("client") &&
policy?.description?.startsWith("Temporary") &&
policy?.description?.endsWith("client")
),
) ?? [],
[policies],
);
const tempPolicies = useMemo(
() => withTemporaryPolicies(true),
[withTemporaryPolicies],
);
const regularPolicies = useMemo(
() => withTemporaryPolicies(false),
[withTemporaryPolicies],
);
useEffect(() => {
if (showTemporaryPolicies && tempPolicies?.length === 0) {
setShowTemporaryPolicies(false);
}
}, [showTemporaryPolicies, tempPolicies]);
return (
<>
{editModal && currentRow && (
@@ -232,8 +268,9 @@ export default function AccessControlTable({
columnVisibility={{
description: false,
id: false,
temporary: false,
}}
data={policies}
data={showTemporaryPolicies ? tempPolicies : regularPolicies}
onRowClick={(row, cell) => {
setCurrentRow(row.original);
setEditModal(true);
@@ -301,66 +338,91 @@ export default function AccessControlTable({
</>
)}
>
{(table) => (
<>
<ButtonGroup disabled={policies?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(undefined);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === undefined
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(true);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === true
? "tertiary"
: "secondary"
}
>
Active
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(false);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === false
? "tertiary"
: "secondary"
}
>
Inactive
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage
table={table}
disabled={policies?.length == 0}
/>
<DataTableRefreshButton
isDisabled={policies?.length == 0}
onClick={() => {
mutate("/policies").then();
mutate("/groups").then();
}}
/>
</>
)}
{(table) => {
return (
<>
<ButtonGroup disabled={policies?.length == 0}>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(undefined);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === undefined
? "tertiary"
: "secondary"
}
>
All
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(true);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === true
? "tertiary"
: "secondary"
}
>
Active
</ButtonGroup.Button>
<ButtonGroup.Button
onClick={() => {
table.setPageIndex(0);
table.getColumn("enabled")?.setFilterValue(false);
}}
disabled={policies?.length == 0}
variant={
table.getColumn("enabled")?.getFilterValue() === false
? "tertiary"
: "secondary"
}
>
Inactive
</ButtonGroup.Button>
</ButtonGroup>
<DataTableRowsPerPage
table={table}
disabled={policies?.length == 0}
/>
{tempPolicies?.length > 0 && (
<FullTooltip
content={
<div className={"max-w-sm text-xs"}>
Show temporary policies created by the NetBird browser
client. These policies are ephemeral and will be
deleted automatically after a short period of time.
</div>
}
>
<Button
className={"h-[44px]"}
variant={showTemporaryPolicies ? "tertiary" : "secondary"}
onClick={() => {
setShowTemporaryPolicies(!showTemporaryPolicies);
}}
>
<ClockFadingIcon size={16} />
</Button>
</FullTooltip>
)}
<DataTableRefreshButton
isDisabled={policies?.length == 0}
onClick={() => {
mutate("/policies").then();
mutate("/groups").then();
}}
/>
</>
);
}}
</DataTable>
</>
);
}
}

View File

@@ -126,6 +126,10 @@ export const useAccessControl = ({
: initialDestinationGroups ?? [],
});
const [sourceResource, setSourceResource] = useState(
firstRule?.sourceResource,
);
const [destinationResource, setDestinationResource] = useState(
firstRule?.destinationResource,
);
@@ -163,8 +167,9 @@ export const useAccessControl = ({
bidirectional: direction == "bi",
description,
name,
sources: sources,
sources: sourceResource ? undefined : sources,
destinations: destinationResource ? undefined : destinations,
sourceResource: sourceResource || undefined,
destinationResource: destinationResource || undefined,
action: "accept",
protocol,
@@ -241,8 +246,9 @@ export const useAccessControl = ({
action: "accept",
protocol,
enabled,
sources,
sources: sourceResource ? undefined : sources,
destinations: destinationResource ? undefined : destinations,
sourceResource: sourceResource || undefined,
destinationResource: destinationResource || undefined,
ports: newPorts,
port_ranges: newPortRanges,
@@ -345,6 +351,8 @@ export const useAccessControl = ({
getPolicyData,
portDisabled,
isPostureChecksLoading,
sourceResource,
setSourceResource,
destinationResource,
setDestinationResource,
destinationHasResources,

View File

@@ -365,6 +365,14 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
if (event.activity_code == "peer.user.add")
return (
<div className={"inline"}>
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
with the NetBird IP <Value>{m.ip}</Value>
</div>
);
/**
* Group
*/

View File

@@ -37,12 +37,13 @@ export default function ActiveInactiveRow({
<div className={"flex gap-2.5 items-start"}>
<CircleIcon
active={active}
size={8}
inactiveDot={inactiveDot}
className={"mt-[0.34rem] shrink-0"}
className={"mt-[0.45rem] shrink-0"}
/>
<div className={"flex flex-col min-w-0"}>
<div
className={"font-medium flex gap-2 items-center justify-center"}
className={"font-medium flex gap-2 items-center justify-start"}
>
<TextWithTooltip text={text as string} maxChars={25} />
{additionalInfo}

View File

@@ -0,0 +1,21 @@
import FullTooltip from "@components/FullTooltip";
import * as React from "react";
import { Peer } from "@/interfaces/Peer";
import {PowerOffIcon} from "lucide-react";
type Props = {
peer: Peer;
};
export const EphemeralPeerIndicator = ({ peer }: Props) => {
if (!peer.ephemeral) {
return null;
}
const tooltipContent = "This peer is an ephemeral peer. If it is disconnected for more than 10 minutes it will be removed.";
return (
<FullTooltip content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}>
<PowerOffIcon size={12} className={"shrink-0 text-yellow-400"} />
</FullTooltip>
);
};

View File

@@ -0,0 +1,23 @@
import FullTooltip from "@components/FullTooltip";
import { TimerResetIcon } from "lucide-react";
import * as React from "react";
import { Peer } from "@/interfaces/Peer";
type Props = {
peer: Peer;
};
export const ExpirationDisabledIndicator = ({ peer }: Props) => {
if (peer.login_expiration_enabled) {
return null;
}
const tooltipContent = "Expiration is disabled for this peer.";
return (
<FullTooltip
content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}
>
<TimerResetIcon size={14} className={"shrink-0 text-nb-gray-300"} />
</FullTooltip>
);
};

View File

@@ -0,0 +1,27 @@
import FullTooltip from "@components/FullTooltip";
import { AlertTriangle } from "lucide-react";
import * as React from "react";
import { Peer } from "@/interfaces/Peer";
type Props = {
peer: Peer;
};
export const LoginRequiredIndicator = ({ peer }: Props) => {
if (!peer.login_expired) {
return null;
}
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
{" "}
This peer is offline and needs to be <br />
re-authenticated because its login has expired.
</div>
}
>
<AlertTriangle size={14} className={"shrink-0 text-red-500"} />
</FullTooltip>
);
};

View File

@@ -64,7 +64,7 @@ export default function PeerActionCell() {
};
return (
<div className={"flex justify-end pr-4"}>
<div className={"flex justify-end pr-4 gap-3"}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}

View File

@@ -23,7 +23,7 @@ export default function PeerAddressCell({ peer }: Props) {
>
<div
className={
"flex gap-4 items-center min-w-[320px] max-w-[320px] group/cell transition-all hover:bg-nb-gray-800/10 py-2 px-3 rounded-md cursor-default"
"flex gap-2.5 items-center min-w-[300px] max-w-[300px] group/cell transition-all hover:bg-nb-gray-800/10 py-2 px-3 rounded-md cursor-default"
}
onClick={(e) => {
e.stopPropagation();
@@ -32,13 +32,13 @@ export default function PeerAddressCell({ peer }: Props) {
>
<div
className={cn(
"flex items-center justify-center rounded-full h-8 w-8 shrink-0 bg-nb-gray-920/80 transition-all",
"flex items-center justify-center rounded-full h-3 w-3 shrink-0 relative -top-[0.5rem]",
)}
>
{isEmpty(peer.country_code) ? (
<GlobeIcon size={16} className={"text-nb-gray-300"} />
) : (
<RoundedFlag country={peer.country_code} size={20} />
<RoundedFlag country={peer.country_code} size={12} />
)}
</div>
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">

View File

@@ -0,0 +1,80 @@
import {
DropdownMenu,
DropdownMenuContent,
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";
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 ? (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
asChild={true}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<div className={"group"}>
<ConnectButton />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-auto"
align="start"
side={"bottom"}
sideOffset={8}
>
<SSHButton peer={peer} isDropdown={true} />
<RDPButton peer={peer} isDropdown={true} />
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<FullTooltip
content={
<div className={"max-w-[200px] text-xs"}>
Connecting via SSH or RDP is only available when the peer is online.
</div>
}
>
<ConnectButton disabled={true} />
</FullTooltip>
);
};
const ConnectButton = ({ disabled }: { disabled?: boolean }) => {
return (
<button
className={cn(
"flex gap-2 items-center text-sm text-nb-gray-300 hover:text-white disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-nb-gray-800/60 rounded-md py-2 px-3 disabled:text-nb-gray-700",
// group data state open
"group-data-[state=open]:bg-nb-gray-800/30",
)}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
Connect
<IconChevronDown size={14} />
</button>
);
};

View File

@@ -6,6 +6,9 @@ import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider";
import { Peer } from "@/interfaces/Peer";
import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
import { ExitNodePeerIndicator } from "@/modules/exit-node/ExitNodePeerIndicator";
import { EphemeralPeerIndicator } from "@/modules/peer/EphemeralPeerIndicator";
import { ExpirationDisabledIndicator } from "@/modules/peer/ExpirationDisabledIndicator";
import { LoginRequiredIndicator } from "@/modules/peer/LoginRequiredIndicator";
type Props = {
peer: Peer;
@@ -27,7 +30,7 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
<div>
<div
className={cn(
"flex items-center max-w-[300px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
"flex items-center max-w-[280px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
linkToPeer &&
"hover:text-neutral-100 hover:bg-nb-gray-800/60 cursor-pointer",
)}
@@ -39,7 +42,14 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
active={peer.connected}
text={peer.name}
additionalInfo={
isOwnerOrAdmin && <ExitNodePeerIndicator peer={peer} />
isOwnerOrAdmin && (
<>
<ExitNodePeerIndicator peer={peer} />
<EphemeralPeerIndicator peer={peer} />
<ExpirationDisabledIndicator peer={peer} />
<LoginRequiredIndicator peer={peer} />
</>
)
}
>
<div className={"text-nb-gray-400 font-light truncate"}>

View File

@@ -0,0 +1,28 @@
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { cn } from "@utils/helpers";
import * as React from "react";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type Props = {
os: string;
className?: string;
};
export const PeerOperatingSystemIcon = ({ os, className }: Props) => {
const operatingSystem = getOperatingSystem(os);
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
operatingSystem === OperatingSystem.WINDOWS && "p-[2.5px]",
operatingSystem === OperatingSystem.APPLE && "p-[2.7px]",
operatingSystem === OperatingSystem.FREEBSD && "p-[1.5px]",
className,
)}
>
<OSLogo os={os} />
</div>
);
};

View File

@@ -2,8 +2,7 @@ import Badge from "@components/Badge";
import Button from "@components/Button";
import FullTooltip from "@components/FullTooltip";
import { notify } from "@components/Notification";
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
import { HelpCircle, TimerResetIcon } from "lucide-react";
import { HelpCircle } from "lucide-react";
import * as React from "react";
import { useSWRConfig } from "swr";
import { useDialog } from "@/contexts/DialogProvider";
@@ -49,46 +48,39 @@ export default function PeerStatusCell({ peer }: Props) {
}
};
return needsApproval ? (
<div className={"flex gap-3 items-center text-xs"}>
<FullTooltip
content={
<div className={"max-w-xs text-xs"}>
The peer needs to be approved by an administrator before it can
connect to other peers.
</div>
}
interactive={false}
>
<Badge variant={"netbird"} className={"px-3 font-medium"}>
<HelpCircle size={12} />
Approval required
</Badge>
</FullTooltip>
<Button
variant={"secondary"}
size={"xs"}
className={"h-[32px]"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!canApprove) return;
approvePeer();
}}
>
Approve
</Button>
</div>
) : (
<div className={"flex gap-3 items-center text-xs"}>
{!peer.login_expiration_enabled && (
<Badge variant={"gray"} className={"px-2"}>
<TimerResetIcon size={13} className={"relative -top-[1px]"} />
Expiration disabled
</Badge>
)}
<LoginExpiredBadge loginExpired={peer.login_expired} />
</div>
);
}
return (
needsApproval && (
<div className={"flex gap-3 items-center text-xs"}>
<FullTooltip
content={
<div className={"max-w-xs text-xs"}>
The peer needs to be approved by an administrator before it can
connect to other peers.
</div>
}
interactive={false}
>
<Badge variant={"netbird"} className={"px-3 font-medium"}>
<HelpCircle size={12} />
Approval required
</Badge>
</FullTooltip>
{ canApprove && (
<Button
variant={"secondary"}
size={"xs"}
className={"h-[32px]"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (!canApprove) return;
approvePeer();
}}
>
Approve
</Button>
)}
</div>
)
);
}

View File

@@ -13,12 +13,15 @@ import * as React from "react";
import { useMemo } from "react";
import { useApplicationContext } from "@/contexts/ApplicationProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
import FullTooltip from "@components/FullTooltip";
type Props = {
version: string;
os: string;
serial?: string;
};
export default function PeerVersionCell({ version, os }: Props) {
export default function PeerVersionCell({ version, os, serial }: Props) {
const { latestVersion, latestUrl } = useApplicationContext();
const updateAvailable = useMemo(() => {
@@ -35,56 +38,83 @@ export default function PeerVersionCell({ version, os }: Props) {
return <ArrowUpCircleIcon size={15} className={"text-netbird"} />;
}, []);
return updateAvailable ? (
<TooltipProvider>
<Tooltip delayDuration={10}>
<TooltipTrigger>
<div className="flex 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 items-center">
<MemoizedNetBirdIcon />
{version == "development" ? "dev" : version}
<div className={"relative"}>
<span className="animate-ping absolute left-0 inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20"></span>
{updateIcon}
return (
<div className={"flex flex-col gap-1"}>
{updateAvailable ? (
<TooltipProvider>
<Tooltip delayDuration={10}>
<TooltipTrigger>
<div className="flex gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all rounded-md items-center">
<MemoizedNetBirdIcon />
{version == "development" ? "dev" : version}
<div className={"relative"}>
<span className="animate-ping absolute left-0 inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20"></span>
{updateIcon}
</div>
</div>
</TooltipTrigger>
<TooltipContent>
<div
className={
" inline-flex gap-2 items-center rounded-md text-xs my-2"
}
>
<MemoizedNetBirdIcon />
<span>{version}</span>
<ArrowRightIcon size={16} className={"text-netbird"} />
<span className={"text-netbird"}>{latestVersion}</span>
</div>
<p className={"font-medium"}>Update available </p>
<div
className={
"text-neutral-300 flex flex-col gap-1 max-w-[300px] text-xs mt-1"
}
>
A new version of Netbird is available. Please update your client
to get the latest features and bug fixes.
</div>
<InlineLink
onClick={(e) => e.stopPropagation()}
href={latestUrl as string}
target={"_blank"}
className={"mt-2 mb-2 text-xs"}
>
Download & Changelog
</InlineLink>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<div className="inline-flex gap-2 dark:text-neutral-300 text-neutral-500 items-center">
<MemoizedNetBirdIcon />
{version == "development" ? "dev" : version}
</div>
)}
{os && os !== "" && os !== " " && (
<FullTooltip
delayDuration={500}
disabled={!serial || serial === ""}
content={
<div className={"text-xs"}>
<span className={"text-nb-gray-100 font-medium"}>Serial: </span>
{serial}
</div>
</div>
</TooltipTrigger>
<TooltipContent>
}
>
<div
className={
" inline-flex gap-2 items-center rounded-md text-xs my-2"
"flex items-center gap-2 text-neutral-300 whitespace-nowrap"
}
>
<MemoizedNetBirdIcon />
<span>{version}</span>
<ArrowRightIcon size={16} className={"text-netbird"} />
<span className={"text-netbird"}>{latestVersion}</span>
</div>
<p className={"font-medium"}>Update available </p>
<PeerOperatingSystemIcon os={os} />
<div
className={
"text-neutral-300 flex flex-col gap-1 max-w-[300px] text-xs mt-1"
}
>
A new version of Netbird is available. Please update your client to
get the latest features and bug fixes.
{os}
</div>
<InlineLink
onClick={(e) => e.stopPropagation()}
href={latestUrl as string}
target={"_blank"}
className={"mt-2 mb-2 text-xs"}
>
Download & Changelog
</InlineLink>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<div className="inline-flex gap-2 dark:text-neutral-300 text-neutral-500 py-2 px-3 items-center">
<MemoizedNetBirdIcon />
{version == "development" ? "dev" : version}
</FullTooltip>
)}
</div>
);
}

View File

@@ -18,7 +18,7 @@ import {
import { uniqBy } from "lodash";
import { ExternalLinkIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import React, { useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import PeerIcon from "@/assets/icons/PeerIcon";
import PeerProvider from "@/contexts/PeerProvider";
@@ -37,6 +37,9 @@ import PeerNameCell from "@/modules/peers/PeerNameCell";
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
import PeerStatusCell from "@/modules/peers/PeerStatusCell";
import PeerVersionCell from "@/modules/peers/PeerVersionCell";
import FullTooltip from "@components/FullTooltip";
import { MonitorDotIcon } from "lucide-react";
import { PeerConnectButton } from "@/modules/peers/PeerConnectButton";
const PeersTableColumns: ColumnDef<Peer>[] = [
{
@@ -72,6 +75,16 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
sortingFn: "text",
cell: ({ row }) => <PeerNameCell peer={row.original} />,
},
{
id: "connect",
accessorKey: "id",
header: "",
cell: ({ row }) => (
<PeerProvider peer={row.original}>
<PeerConnectButton />
</PeerProvider>
),
},
{
id: "approval_required",
accessorKey: "approval_required",
@@ -157,7 +170,11 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
return <DataTableHeader column={column}>Version</DataTableHeader>;
},
cell: ({ row }) => (
<PeerVersionCell version={row.original.version} os={row.original.os} />
<PeerVersionCell
version={row.original.version}
os={row.original.os}
serial={row.original.serial_number}
/>
),
},
{
@@ -243,6 +260,32 @@ export default function PeersTable({
}
};
const [showBrowserPeers, setShowBrowserPeers] = useState(false);
const withBrowserPeers = useCallback(
(condition: boolean) =>
peers?.filter((peer) =>
condition
? peer.kernel_version === "wasm"
: peer.kernel_version !== "wasm",
) ?? [],
[peers],
);
const browserPeers = useMemo(() => {
return withBrowserPeers(true);
}, [withBrowserPeers]);
const regularPeers = useMemo(() => {
return withBrowserPeers(false);
}, [withBrowserPeers]);
useEffect(() => {
if (showBrowserPeers && browserPeers?.length === 0) {
setShowBrowserPeers(false);
}
}, [showBrowserPeers, browserPeers]);
return (
<>
<PeerMultiSelect
@@ -258,7 +301,7 @@ export default function PeersTable({
sorting={sorting}
setSorting={setSorting}
columns={PeersTableColumns}
data={peers}
data={showBrowserPeers ? browserPeers : regularPeers}
searchPlaceholder={"Search by name, IP, owner or group..."}
columnVisibility={{
select: permission.groups.read,
@@ -271,7 +314,9 @@ export default function PeersTable({
user_name: false,
user_email: false,
actions: permission.peers.update,
connect: permission.peers.update,
groups: permission.groups.read,
os: false,
}}
isLoading={isLoading}
getStartedCard={
@@ -471,6 +516,28 @@ export default function PeersTable({
/>
)}
{browserPeers?.length > 0 && (
<FullTooltip
content={
<div className={"max-w-sm text-xs"}>
Show temporary peers created by the NetBird browser client.
These peers are ephemeral and will be deleted automatically
after a short period of time.
</div>
}
>
<Button
className={"h-[44px]"}
variant={showBrowserPeers ? "tertiary" : "secondary"}
onClick={() => {
setShowBrowserPeers(!showBrowserPeers);
}}
>
<MonitorDotIcon size={16} />
</Button>
</FullTooltip>
)}
<DataTableRefreshButton
isDisabled={peers?.length == 0}
onClick={() => {

View File

@@ -0,0 +1,72 @@
import Button from "@components/Button";
import { DropdownMenuItem } from "@components/DropdownMenu";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { CircleHelpIcon, MonitorIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { Peer } from "@/interfaces/Peer";
import { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip";
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
type Props = {
peer: Peer;
isDropdown?: boolean;
};
export const RDPButton = ({ peer, isDropdown = false }: Props) => {
const [modal, setModal] = useState(false);
const { permission } = usePermissions();
const disabled = !peer.connected || !permission.peers.update;
const hasPermission = permission.peers.update;
const isWindows = getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS;
const openRDPPage = () => {
window.open(
`peer/rdp?id=${peer.id}`,
"_blank",
"noopener,noreferrer,width=1200,height=650,left=100,top=100,location=no,toolbar=no,menubar=no,status=no",
);
};
return (
isWindows && (
<>
<div>
<RDPTooltip
disabled={!disabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
>
{isDropdown ? (
<DropdownMenuItem
onClick={openRDPPage}
disabled={disabled}
className={"w-full"}
>
<div className={"flex gap-3 items-center w-full"}>
<MonitorIcon size={14} className={"shrink-0"} />
RDP
</div>
</DropdownMenuItem>
) : (
<Button
variant="secondary"
size="sm"
onClick={openRDPPage}
disabled={disabled}
>
<MonitorIcon size={16} />
RDP
{disabled && <CircleHelpIcon size={12} />}
</Button>
)}
</RDPTooltip>
</div>
</>
)
);
};

View File

@@ -0,0 +1,170 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import { Checkbox } from "@components/Checkbox";
import HelpText from "@components/HelpText";
import { Label } from "@components/Label";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import Separator from "@components/Separator";
import { LockIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import {
CertificateInfo,
CertificatePromptInfo,
} from "./useRDPCertificateHandler";
type Props = {
open: boolean;
certificateInfo: CertificatePromptInfo | null;
onAccept: (remember: boolean) => void;
onReject: () => void;
};
export const RDPCertificateModal = ({
open,
certificateInfo,
onAccept,
onReject,
}: Props) => {
const [rememberCertificate, setRememberCertificate] = useState(false);
if (!certificateInfo) return null;
const { hostname, certificate, isChange } = certificateInfo;
return (
<Modal open={open} onOpenChange={undefined}>
<ModalContent maxWidthClass={"max-w-2xl"} showClose={false}>
<ModalHeader
icon={<LockIcon className={"text-netbird"} size={18} />}
title={"RDP Certificate"}
description={hostname}
color={"netbird"}
/>
<Separator />
<div className={"px-8 py-6 flex flex-col gap-6"}>
{isChange && (
<Callout variant={"warning"}>
Warning! Certificate has changed. Only proceed if you trust this
connection.
</Callout>
)}
<div>
<Label>Certificate Details</Label>
<HelpText>
Certificated could not be verified by a trusted authority. Review
the certificate information before proceeding with the connection.
</HelpText>
<CertificateDetailsList certificate={certificate} />
</div>
<label className={"flex items-center space-x-3 cursor-pointer"}>
<Checkbox
id="remember-cert"
checked={rememberCertificate}
variant={"tableCell"}
onCheckedChange={(checked) =>
setRememberCertificate(checked === true)
}
/>
<div className={"font-normal text-sm text-nb-gray-200"}>
Always trust{" "}
<span className={"text-white font-medium"}>
{'"' + certificate?.issuer?.replace("CN=", "") + '"'}
</span>{" "}
when connecting to{" "}
<span className={"text-white font-medium"}>
{'"' + hostname + '"'}
</span>
</div>
</label>
</div>
<ModalFooter className={"items-center"}>
<div className={"flex gap-3 w-full justify-end"}>
<Button variant={"secondary"} onClick={onReject}>
Cancel
</Button>
<Button
variant={"primary"}
onClick={() => onAccept(rememberCertificate)}
>
Accept & Continue
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
};
const CertificateDetailsList = ({
certificate,
}: {
certificate: CertificateInfo;
}) => {
if (!certificate) return null;
return (
<div
className={
"bg-nb-gray-930 border border-nb-gray-900 rounded-md mt-3 flex flex-col py-3 px-4 gap-2"
}
>
<CertificateDetailsListItem
label={"Issuer"}
value={certificate.issuer || "N/A"}
/>
<CertificateDetailsListItem
label={"Subject"}
value={certificate.subject || "N/A"}
/>
<CertificateDetailsListItem
label={"Valid From"}
value={
certificate.validFrom
? new Date(certificate.validFrom).toLocaleString()
: "N/A"
}
/>
<CertificateDetailsListItem
label={"Valid To"}
value={
certificate.validTo
? new Date(certificate.validTo).toLocaleString()
: "N/A"
}
/>
<CertificateDetailsListItem
label={"Key Size"}
value={certificate.keySize ? `${certificate.keySize} bits` : "N/A"}
/>
<CertificateDetailsListItem
label={"Serial Number"}
value={certificate.serialNumber || "N/A"}
/>
<CertificateDetailsListItem
label={"Fingerprint"}
value={certificate.fingerprint || "N/A"}
/>
</div>
);
};
const CertificateDetailsListItem = ({
label,
value,
}: {
label: string;
value: string;
}) => {
return (
<div key={label} className={"flex justify-between text-xs gap-10"}>
<span className={"font-mono text-nb-gray-200 w-[200px]"}>{label}:</span>
<span className={"font-mono text-nb-gray-300 break-all text-left w-full"}>
{value}
</span>
</div>
);
};

View File

@@ -0,0 +1,200 @@
import * as React from "react";
import { useCallback, useMemo, useState } from "react";
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { Peer } from "@/interfaces/Peer";
import {
ChevronsLeftRightEllipsis,
ExternalLinkIcon,
KeyRoundIcon,
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 {
RDP_DOCS_LINK,
RDPCredentials,
} from "@/modules/remote-access/rdp/useRemoteDesktop";
import { IconLoader2 } from "@tabler/icons-react";
type Props = {
open: boolean;
peer: Peer;
onConnect?: (credentials: RDPCredentials) => void;
error?: string;
loading?: boolean;
};
export const RDPCredentialsModal = ({
open,
peer,
onConnect,
error,
loading,
}: Props) => {
const [username, setUsername] = useState("Administrator");
const [password, setPassword] = useState("");
const [port, setPort] = useState("3389");
const userNameError = useMemo(() => {
if (username?.length === 0) return "Username cannot be empty";
}, [username]);
const portError = useMemo(() => {
const portNumber = Number(port);
const isValid =
Number.isInteger(portNumber) && portNumber > 0 && portNumber <= 65535;
if (!isValid) return "Port must be a number between 1 and 65535";
}, [port]);
const hasAnyError = useMemo(() => {
if (userNameError !== undefined) return true;
return portError !== undefined;
}, [userNameError, portError]);
const handleConnect = useCallback(() => {
if (hasAnyError || !onConnect) return;
onConnect({
username,
password,
port: Number(port),
});
}, [hasAnyError, onConnect, username, password, port]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !hasAnyError && !loading) {
handleConnect();
}
},
[handleConnect, hasAnyError, loading],
);
return (
<Modal open={open} onOpenChange={undefined}>
<ModalContent maxWidthClass={"max-w-xl"} showClose={false}>
<ModalHeader
icon={<MonitorIcon className={"text-netbird"} size={18} />}
title={peer.name}
description={`Connect to ${peer.ip} via RDP`}
color={"netbird"}
/>
<Separator />
<form
className={"px-8 py-6 flex flex-col gap-8"}
onSubmit={(e) => {
e.preventDefault();
handleConnect();
}}
>
{error && (
<div className={"bg-red-50 border border-red-200 rounded-md p-4"}>
<div
className={
"flex items-center gap-2 text-red-800 font-medium mb-1"
}
>
Error
</div>
<p className={"text-sm text-red-700"}>{error}</p>
</div>
)}
<div>
<Label>Username & Password</Label>
<HelpText>
Enter the credentials required to authenticate with the remote
host.
</HelpText>
<div className={"flex flex-col gap-2 w-full"}>
<Input
placeholder={"Administrator"}
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}
name="username"
autoComplete={"username"}
error={userNameError}
errorTooltip={true}
errorTooltipPosition={"top-right"}
customPrefix={
<User2 size={16} className={"text-nb-gray-300"} />
}
/>
<Input
value={password}
placeholder={"Enter password"}
type={"password"}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
name="password"
autoComplete={"current-password"}
error={undefined}
errorTooltip={true}
errorTooltipPosition={"top-right"}
customPrefix={
<KeyRoundIcon size={16} className={"text-nb-gray-300"} />
}
/>
</div>
</div>
<div>
<Label>Port</Label>
<HelpText>
Specify the RDP port for your remote connection.
</HelpText>
<Input
maxWidthClass={""}
placeholder={"3389"}
min={1}
max={65535}
value={port}
type={"number"}
error={portError}
errorTooltip={true}
errorTooltipPosition={"top-right"}
onChange={(e) => setPort(e.target.value)}
onKeyDown={handleKeyDown}
customPrefix={
<ChevronsLeftRightEllipsis
size={16}
className={"text-nb-gray-300"}
/>
}
/>
</div>
</form>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={RDP_DOCS_LINK} target={"_blank"}>
RDP
<ExternalLinkIcon size={12} />
</InlineLink>
</Paragraph>
</div>
<div className={"flex gap-3 w-full justify-end"}>
<Button
type="submit"
variant={"primary"}
disabled={hasAnyError || loading}
onClick={handleConnect}
>
{loading && <IconLoader2 size={16} className={"animate-spin"} />}
Connect
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,37 @@
import FullTooltip from "@components/FullTooltip";
import * as React from "react";
type Props = {
disabled?: boolean;
children?: React.ReactNode;
hasPermission?: boolean;
side?: "top" | "right" | "bottom" | "left";
};
export const RDPTooltip = ({
disabled,
children,
hasPermission,
side = "top",
}: Props) => {
return (
<FullTooltip
className={"w-full"}
side={side}
content={
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
{hasPermission ? (
<div>This peer is offline and cannot be accessed via RDP.</div>
) : (
<div>
You do not have permission to launch an RDP session. Please
contact your administrator.
</div>
)}
</div>
}
disabled={disabled}
>
{children}
</FullTooltip>
);
};

View File

@@ -0,0 +1,809 @@
/**
* IronRDP Input Handler for NetBird WASM Client
* Handles mouse and keyboard input for IronRDP sessions
*/
import type { IronRDPModule, RDPSession } from "./ironrdp-wasm-bridge";
interface DeviceEvent {
free?(): void;
}
interface InputTransaction {
addEvent(event: DeviceEvent): void;
free?(): void;
}
interface IronRDPAPI extends IronRDPModule {
DeviceEvent: {
mouseButtonPressed(button: number): DeviceEvent;
mouseButtonReleased(button: number): DeviceEvent;
mouseMove(x: number, y: number): DeviceEvent;
wheelRotations(isVertical: boolean, rotationUnits: number): DeviceEvent;
keyPressed(scancode: number): DeviceEvent;
keyReleased(scancode: number): DeviceEvent;
unicode(code: number): DeviceEvent;
};
InputTransaction: new () => InputTransaction;
}
interface ExtendedRDPSession extends RDPSession {
applyInputs(transaction: InputTransaction): void;
}
interface CoordinateResult {
x: number;
y: number;
}
declare global {
interface Window {
toggleFullscreen?: () => void;
}
}
export class IronRDPInputHandler {
private ironrdp: IronRDPAPI;
private session: ExtendedRDPSession;
private canvas: HTMLCanvasElement;
private isActive = false;
private mouseButtonStates: Record<number, boolean> = {
0: false,
1: false,
2: false,
};
private keyStates = new Map<string, boolean>();
private currentMouseX = 0;
private currentMouseY = 0;
// Bound event handlers for proper cleanup
private boundHandlers = {
mouseDown: this.handleMouseDown.bind(this),
mouseUp: this.handleMouseUp.bind(this),
mouseMove: this.handleMouseMove.bind(this),
mouseEnter: this.handleMouseEnter.bind(this),
wheel: this.handleWheel.bind(this),
touchStart: this.handleTouchStart.bind(this),
touchMove: this.handleTouchMove.bind(this),
touchEnd: this.handleTouchEnd.bind(this),
keyDown: this.handleKeyDown.bind(this),
keyUp: this.handleKeyUp.bind(this),
paste: this.handlePaste.bind(this),
copy: this.handleCopy.bind(this),
contextMenu: (e: Event) => e.preventDefault(),
focus: this.handleFocus.bind(this),
blur: this.handleBlur.bind(this),
click: this.handleClick.bind(this),
globalKeyDown: this.handleGlobalKeyDown.bind(this),
};
// Keyboard code to scancode mappings (using event.code instead of deprecated keyCode)
private readonly codeToScancode: Record<string, number> = {
// Letters
KeyA: 0x1e,
KeyB: 0x30,
KeyC: 0x2e,
KeyD: 0x20,
KeyE: 0x12,
KeyF: 0x21,
KeyG: 0x22,
KeyH: 0x23,
KeyI: 0x17,
KeyJ: 0x24,
KeyK: 0x25,
KeyL: 0x26,
KeyM: 0x32,
KeyN: 0x31,
KeyO: 0x18,
KeyP: 0x19,
KeyQ: 0x10,
KeyR: 0x13,
KeyS: 0x1f,
KeyT: 0x14,
KeyU: 0x16,
KeyV: 0x2f,
KeyW: 0x11,
KeyX: 0x2d,
KeyY: 0x15,
KeyZ: 0x2c,
// Numbers
Digit0: 0x0b,
Digit1: 0x02,
Digit2: 0x03,
Digit3: 0x04,
Digit4: 0x05,
Digit5: 0x06,
Digit6: 0x07,
Digit7: 0x08,
Digit8: 0x09,
Digit9: 0x0a,
// Function keys
F1: 0x3b,
F2: 0x3c,
F3: 0x3d,
F4: 0x3e,
F5: 0x3f,
F6: 0x40,
F7: 0x41,
F8: 0x42,
F9: 0x43,
F10: 0x44,
F11: 0x57,
F12: 0x58,
// Special keys
Backspace: 0x0e,
Tab: 0x0f,
Enter: 0x1c,
ShiftLeft: 0x2a,
ShiftRight: 0x36,
ControlLeft: 0x1d,
ControlRight: 0x9d,
AltLeft: 0x38,
AltRight: 0xb8,
CapsLock: 0x3a,
Escape: 0x01,
Space: 0x39,
PageUp: 0xe049,
PageDown: 0xe051,
End: 0xe04f,
Home: 0xe047,
ArrowLeft: 0xe04b,
ArrowUp: 0xe048,
ArrowRight: 0xe04d,
ArrowDown: 0xe050,
Insert: 0xe052,
Delete: 0xe053,
MetaLeft: this.isMacOS() ? 0x1d : 0x5b,
MetaRight: this.isMacOS() ? 0x9d : 0x5c,
// Punctuation
Semicolon: 0x27,
Equal: 0x0d,
Comma: 0x33,
Minus: 0x0c,
Period: 0x34,
Slash: 0x35,
Backquote: 0x29,
BracketLeft: 0x1a,
Backslash: 0x2b,
BracketRight: 0x1b,
Quote: 0x28,
// Numpad keys
Numpad0: 0x52,
Numpad1: 0x4f,
Numpad2: 0x50,
Numpad3: 0x51,
Numpad4: 0x4b,
Numpad5: 0x4c,
Numpad6: 0x4d,
Numpad7: 0x47,
Numpad8: 0x48,
Numpad9: 0x49,
NumpadDecimal: 0x53,
NumpadDivide: 0xe035,
NumpadMultiply: 0x37,
NumpadSubtract: 0x4a,
NumpadAdd: 0x4e,
NumpadEnter: 0xe01c,
NumLock: 0x45,
// System keys
PrintScreen: 0xe037,
ScrollLock: 0x46,
Pause: 0xe11d,
// Additional Windows/Context keys
ContextMenu: 0xe05d,
// Additional function keys (F13-F24)
F13: 0x64,
F14: 0x65,
F15: 0x66,
F16: 0x67,
F17: 0x68,
F18: 0x69,
F19: 0x6a,
F20: 0x6b,
F21: 0x6c,
F22: 0x6d,
F23: 0x6e,
F24: 0x76,
// Media keys
AudioVolumeDown: 0xe02e,
AudioVolumeUp: 0xe030,
AudioVolumeMute: 0xe020,
MediaPlayPause: 0xe022,
MediaStop: 0xe024,
MediaTrackPrevious: 0xe010,
MediaTrackNext: 0xe019,
// Browser/Application keys
BrowserBack: 0xe06a,
BrowserForward: 0xe069,
BrowserRefresh: 0xe067,
BrowserStop: 0xe068,
BrowserSearch: 0xe065,
BrowserFavorites: 0xe066,
BrowserHome: 0xe032,
LaunchMail: 0xe06c,
LaunchApp1: 0xe06b,
LaunchApp2: 0xe021,
// International keys
IntlBackslash: 0x56,
IntlRo: 0x73,
IntlYen: 0x7d,
};
private readonly mouseButtonMap: Record<number, number> = {
0: 0,
1: 1,
2: 2,
};
private touchState = {
lastX: 0,
lastY: 0,
touching: false,
};
constructor(
ironrdp: IronRDPModule,
session: RDPSession,
canvas: HTMLCanvasElement,
) {
this.ironrdp = ironrdp as IronRDPAPI;
this.session = session as ExtendedRDPSession;
this.canvas = canvas;
this.setupEventListeners();
// Initialize mouse position to unknown - will be set on first mouse event
this.currentMouseX = -1;
this.currentMouseY = -1;
}
/**
* Detect if the current platform is macOS
*/
private isMacOS(): boolean {
if ("userAgentData" in navigator && (navigator as any).userAgentData) {
return (navigator as any).userAgentData.platform === "macOS";
}
// Fallback
return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
}
/**
* Calculate canvas coordinates with letterbox correction for fullscreen mode
*/
private getCanvasCoordinates(
clientX: number,
clientY: number,
): CoordinateResult {
const rect = this.canvas.getBoundingClientRect();
// Calculate the actual rendered size of the canvas content
const canvasAspectRatio = this.canvas.width / this.canvas.height;
const containerAspectRatio = rect.width / rect.height;
let renderWidth: number,
renderHeight: number,
offsetX: number,
offsetY: number;
// Check if we're using object-fit: contain (letterboxing)
const isFullscreen =
document.fullscreenElement === this.canvas ||
document.fullscreenElement === this.canvas.parentElement;
const hasLetterbox = isFullscreen && this.canvas.style.objectFit !== "fill";
if (hasLetterbox && canvasAspectRatio !== containerAspectRatio) {
// Calculate actual rendered dimensions with letterboxing
if (canvasAspectRatio > containerAspectRatio) {
// Canvas is wider - letterbox on top/bottom
renderWidth = rect.width;
renderHeight = rect.width / canvasAspectRatio;
offsetX = 0;
offsetY = (rect.height - renderHeight) / 2;
} else {
// Canvas is taller - letterbox on left/right
renderWidth = rect.height * canvasAspectRatio;
renderHeight = rect.height;
offsetX = (rect.width - renderWidth) / 2;
offsetY = 0;
}
} else {
// No letterboxing - canvas fills the entire rect
renderWidth = rect.width;
renderHeight = rect.height;
offsetX = 0;
offsetY = 0;
}
// Calculate scale factors based on actual render size
const scaleX = this.canvas.width / renderWidth;
const scaleY = this.canvas.height / renderHeight;
// Adjust coordinates for letterbox offset
const relativeX = clientX - rect.left - offsetX;
const relativeY = clientY - rect.top - offsetY;
// Clamp to valid canvas area
const x = Math.max(
0,
Math.min(this.canvas.width - 1, Math.round(relativeX * scaleX)),
);
const y = Math.max(
0,
Math.min(this.canvas.height - 1, Math.round(relativeY * scaleY)),
);
return { x, y };
}
private setupEventListeners(): void {
this.canvas.tabIndex = 1;
this.canvas.style.outline = "none";
// Mouse events
this.canvas.addEventListener("mousedown", this.boundHandlers.mouseDown);
this.canvas.addEventListener("mouseup", this.boundHandlers.mouseUp);
this.canvas.addEventListener("mousemove", this.boundHandlers.mouseMove);
this.canvas.addEventListener("mouseenter", this.boundHandlers.mouseEnter);
this.canvas.addEventListener("wheel", this.boundHandlers.wheel);
this.canvas.addEventListener("contextmenu", this.boundHandlers.contextMenu);
// Touch events
this.canvas.addEventListener("touchstart", this.boundHandlers.touchStart);
this.canvas.addEventListener("touchmove", this.boundHandlers.touchMove);
this.canvas.addEventListener("touchend", this.boundHandlers.touchEnd);
// Keyboard events
this.canvas.addEventListener("keydown", this.boundHandlers.keyDown);
this.canvas.addEventListener("keyup", this.boundHandlers.keyUp);
this.canvas.addEventListener("paste", this.boundHandlers.paste);
this.canvas.addEventListener("copy", this.boundHandlers.copy);
// Focus events
this.canvas.addEventListener("focus", this.boundHandlers.focus);
this.canvas.addEventListener("blur", this.boundHandlers.blur);
this.canvas.addEventListener("click", this.boundHandlers.click);
// Global keyboard shortcuts
document.addEventListener("keydown", this.boundHandlers.globalKeyDown);
}
private updateVisualIndicator(active: boolean): void {
if (!this.canvas.parentElement) return;
const controls = this.canvas.parentElement.querySelector(
"#rdpControls",
) as HTMLElement;
if (!controls) return;
controls.style.borderBottom = active ? "2px solid #4CAF50" : "none";
}
private handleGlobalKeyDown(e: KeyboardEvent): void {
if (!this.isActive) return;
// F11 for fullscreen toggle
if (e.key === "F11") {
e.preventDefault();
if (window.toggleFullscreen) {
window.toggleFullscreen();
}
}
// Ctrl+Alt+Enter for fullscreen toggle
else if (e.ctrlKey && e.altKey && e.key === "Enter") {
e.preventDefault();
if (window.toggleFullscreen) {
window.toggleFullscreen();
}
}
}
private handleFocus(): void {
this.isActive = true;
this.updateVisualIndicator(true);
// Trigger clipboard sync when canvas gains focus
this.requestClipboardSync();
}
private handleBlur(): void {
this.isActive = false;
this.releaseAllKeys();
this.updateVisualIndicator(false);
}
private handleClick(): void {
this.canvas.focus();
// Also sync clipboard on click to ensure paste works
this.requestClipboardSync();
}
private handlePaste(event: ClipboardEvent): void {
if (!this.isActive) return;
// Only prevent default if we successfully handle the paste
const clipboardData = event.clipboardData;
if (!clipboardData) return;
const text = clipboardData.getData("text/plain");
if (!text) return;
// Prevent the default paste behavior only after we have the text
event.preventDefault();
// Send Ctrl+V combination to RDP session first to maintain consistency
this.sendPasteKeyCombination();
// Then send the actual text content
this.sendTextAsKeystrokes(text);
}
private handleCopy(event: ClipboardEvent): void {
if (!this.isActive) return;
// Send Ctrl+C combination to RDP session
this.sendCopyKeyCombination();
// Let the browser handle the actual copy operation
// Don't prevent default - we want the browser to copy from the RDP canvas
}
private sendPasteKeyCombination(): void {
if (!this.session || !this.ironrdp) return;
try {
const transaction = new this.ironrdp.InputTransaction();
// Send Ctrl (or Cmd on Mac) key down
const ctrlScancode = this.isMacOS()
? this.codeToScancode.MetaLeft
: this.codeToScancode.ControlLeft;
const vScancode = this.codeToScancode.KeyV;
if (ctrlScancode && vScancode) {
// Ctrl/Cmd down
const ctrlDown = this.ironrdp.DeviceEvent.keyPressed(ctrlScancode);
transaction.addEvent(ctrlDown);
// V down
const vDown = this.ironrdp.DeviceEvent.keyPressed(vScancode);
transaction.addEvent(vDown);
// V up
const vUp = this.ironrdp.DeviceEvent.keyReleased(vScancode);
transaction.addEvent(vUp);
// Ctrl/Cmd up
const ctrlUp = this.ironrdp.DeviceEvent.keyReleased(ctrlScancode);
transaction.addEvent(ctrlUp);
this.session.applyInputs(transaction);
}
} catch (err) {
console.error("Error sending paste key combination:", err);
}
}
private sendCopyKeyCombination(): void {
if (!this.session || !this.ironrdp) return;
try {
const transaction = new this.ironrdp.InputTransaction();
// Send Ctrl (or Cmd on Mac) key down
const ctrlScancode = this.isMacOS()
? this.codeToScancode.MetaLeft
: this.codeToScancode.ControlLeft;
const cScancode = this.codeToScancode.KeyC;
if (ctrlScancode && cScancode) {
// Ctrl/Cmd down
const ctrlDown = this.ironrdp.DeviceEvent.keyPressed(ctrlScancode);
transaction.addEvent(ctrlDown);
// C down
const cDown = this.ironrdp.DeviceEvent.keyPressed(cScancode);
transaction.addEvent(cDown);
// C up
const cUp = this.ironrdp.DeviceEvent.keyReleased(cScancode);
transaction.addEvent(cUp);
// Ctrl/Cmd up
const ctrlUp = this.ironrdp.DeviceEvent.keyReleased(ctrlScancode);
transaction.addEvent(ctrlUp);
this.session.applyInputs(transaction);
}
} catch (err) {
console.error("Error sending copy key combination:", err);
}
}
private sendTextAsKeystrokes(text: string): void {
if (!this.session || !this.ironrdp) return;
try {
const transaction = new this.ironrdp.InputTransaction();
// Send each character as unicode event
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
const deviceEvent = this.ironrdp.DeviceEvent.unicode(charCode);
transaction.addEvent(deviceEvent);
}
this.session.applyInputs(transaction);
} catch (err) {
console.error("Error sending paste text:", err);
}
}
private requestClipboardSync(): void {
// Notify the WASM bridge to check and sync clipboard, only for chrome for now, firefox and safari have issues with this
if (!/Chrome/.test(navigator.userAgent)) {
return;
}
if (
window.IronRDPBridge &&
(window.IronRDPBridge as any).checkAndSendClipboard
) {
setTimeout(() => {
(window.IronRDPBridge as any).checkAndSendClipboard();
}, 50);
}
}
private handleMouseDown(event: MouseEvent): void {
event.preventDefault();
this.canvas.focus();
if (!this.isActive) {
this.isActive = true;
}
const { x, y } = this.getCanvasCoordinates(event.clientX, event.clientY);
const button = this.mouseButtonMap[event.button];
if (button === undefined) return;
if (this.mouseButtonStates[event.button]) return;
this.mouseButtonStates[event.button] = true;
try {
if (!this.session || !this.ironrdp) return;
const transaction = new this.ironrdp.InputTransaction();
// Always send mouse position first, then the button press
const moveEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
const clickEvent = this.ironrdp.DeviceEvent.mouseButtonPressed(button);
transaction.addEvent(moveEvent);
transaction.addEvent(clickEvent);
this.session.applyInputs(transaction);
this.currentMouseX = x;
this.currentMouseY = y;
} catch (err) {
console.error("Error sending mouse down:", err);
}
}
private handleMouseUp(event: MouseEvent): void {
event.preventDefault();
const button = this.mouseButtonMap[event.button];
if (button === undefined) return;
if (!this.mouseButtonStates[event.button]) return;
this.mouseButtonStates[event.button] = false;
try {
if (!this.session || !this.ironrdp) return;
const deviceEvent = this.ironrdp.DeviceEvent.mouseButtonReleased(button);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(deviceEvent);
this.session.applyInputs(transaction);
} catch (err) {
console.error("Error sending mouse up:", err);
}
}
private handleMouseEnter(event: MouseEvent): void {
// Always sync position when entering
this.handleMouseMove(event);
}
private handleMouseMove(event: MouseEvent): void {
const { x, y } = this.getCanvasCoordinates(event.clientX, event.clientY);
// Skip if position hasn't changed
if (x === this.currentMouseX && y === this.currentMouseY) {
return;
}
this.currentMouseX = x;
this.currentMouseY = y;
try {
if (!this.session || !this.ironrdp) return;
const deviceEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(deviceEvent);
this.session.applyInputs(transaction);
} catch (err) {
console.error("Error sending mouse move:", err);
}
}
private handleWheel(event: WheelEvent): void {
event.preventDefault();
if (!this.isActive) return;
// Calculate rotation units (120 units = 1 notch)
const delta = event.deltaY > 0 ? -1 : 1;
const rotationUnits = delta * 120;
try {
if (!this.session || !this.ironrdp) return;
const deviceEvent = this.ironrdp.DeviceEvent.wheelRotations(
true,
rotationUnits,
);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(deviceEvent);
this.session.applyInputs(transaction);
} catch (err) {
console.error("Error sending wheel event:", err);
}
}
private handleKeyDown(event: KeyboardEvent): void {
if (!this.isActive) return;
// For clipboard operations, don't prevent default to allow clipboard events to fire
const isClipboardPaste =
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v";
const isClipboardCopy =
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c";
if (!isClipboardPaste && !isClipboardCopy) {
event.preventDefault();
}
// Don't send clipboard combinations here - let the clipboard events handle them (only for Chromium browsers)
const isChromium = /Chrome/.test(navigator.userAgent);
if ((isClipboardPaste || isClipboardCopy) && isChromium) {
return;
}
const scancode = this.codeToScancode[event.code];
if (scancode !== undefined) {
try {
if (!this.session || !this.ironrdp) return;
const deviceEvent = this.ironrdp.DeviceEvent.keyPressed(scancode);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(deviceEvent);
this.session.applyInputs(transaction);
} catch (err) {
console.error("Error sending key down:", err);
}
} else if (event.key.length === 1) {
// For printable characters not in our scancode map, use unicode
try {
if (!this.session || !this.ironrdp) return;
const charCode = event.key.charCodeAt(0);
const deviceEvent = this.ironrdp.DeviceEvent.unicode(charCode);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(deviceEvent);
this.session.applyInputs(transaction);
} catch (err) {
console.error("Error sending unicode char:", err);
}
}
}
private handleKeyUp(event: KeyboardEvent): void {
if (!this.isActive) return;
// For clipboard operations, don't prevent default to allow clipboard events to fire
const isClipboardPaste =
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v";
const isClipboardCopy =
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c";
if (!isClipboardPaste && !isClipboardCopy) {
event.preventDefault();
}
// Don't send clipboard combinations here - let the clipboard events handle them
const isChromium = /Chrome/.test(navigator.userAgent);
if ((isClipboardPaste || isClipboardCopy) && isChromium) {
return;
}
const scancode = this.codeToScancode[event.code];
if (scancode === undefined) return;
try {
if (!this.session || !this.ironrdp) return;
const deviceEvent = this.ironrdp.DeviceEvent.keyReleased(scancode);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(deviceEvent);
this.session.applyInputs(transaction);
} catch (err) {
console.error("Error sending key up:", err);
}
}
private handleTouchStart(event: TouchEvent): void {
event.preventDefault();
if (event.touches.length !== 1) return;
const touch = event.touches[0];
const { x, y } = this.getCanvasCoordinates(touch.clientX, touch.clientY);
this.touchState.lastX = x;
this.touchState.lastY = y;
this.touchState.touching = true;
// Simulate mouse down
try {
if (!this.session || !this.ironrdp) return;
const moveEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
const clickEvent = this.ironrdp.DeviceEvent.mouseButtonPressed(0);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(moveEvent);
transaction.addEvent(clickEvent);
this.session.applyInputs(transaction);
this.currentMouseX = x;
this.currentMouseY = y;
} catch (err) {
console.error("Error handling touch start:", err);
}
}
private handleTouchMove(event: TouchEvent): void {
event.preventDefault();
if (!this.touchState.touching || event.touches.length !== 1) return;
const touch = event.touches[0];
const { x, y } = this.getCanvasCoordinates(touch.clientX, touch.clientY);
if (x === this.touchState.lastX && y === this.touchState.lastY) return;
this.touchState.lastX = x;
this.touchState.lastY = y;
try {
if (!this.session || !this.ironrdp) return;
const deviceEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(deviceEvent);
this.session.applyInputs(transaction);
this.currentMouseX = x;
this.currentMouseY = y;
} catch (err) {
console.error("Error handling touch move:", err);
}
}
private handleTouchEnd(event: TouchEvent): void {
event.preventDefault();
if (!this.touchState.touching) return;
this.touchState.touching = false;
// Simulate mouse up
try {
if (!this.session || !this.ironrdp) return;
const deviceEvent = this.ironrdp.DeviceEvent.mouseButtonReleased(0);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(deviceEvent);
this.session.applyInputs(transaction);
} catch (err) {
console.error("Error handling touch end:", err);
}
}
private releaseAllKeys(): void {
this.keyStates.forEach((pressed, code) => {
if (!pressed) return;
const scancode = this.codeToScancode[code];
if (scancode === undefined) return;
try {
if (!this.session || !this.ironrdp) return;
const deviceEvent = this.ironrdp.DeviceEvent.keyReleased(scancode);
const transaction = new this.ironrdp.InputTransaction();
transaction.addEvent(deviceEvent);
this.session.applyInputs(transaction);
} catch (err) {
console.error("Error releasing key:", err);
}
});
this.keyStates.clear();
}
destroy(): void {
// Remove all event listeners using the same bound functions
this.canvas.removeEventListener("mousedown", this.boundHandlers.mouseDown);
this.canvas.removeEventListener("mouseup", this.boundHandlers.mouseUp);
this.canvas.removeEventListener("mousemove", this.boundHandlers.mouseMove);
this.canvas.removeEventListener(
"mouseenter",
this.boundHandlers.mouseEnter,
);
this.canvas.removeEventListener("wheel", this.boundHandlers.wheel);
this.canvas.removeEventListener(
"contextmenu",
this.boundHandlers.contextMenu,
);
this.canvas.removeEventListener(
"touchstart",
this.boundHandlers.touchStart,
);
this.canvas.removeEventListener("touchmove", this.boundHandlers.touchMove);
this.canvas.removeEventListener("touchend", this.boundHandlers.touchEnd);
this.canvas.removeEventListener("keydown", this.boundHandlers.keyDown);
this.canvas.removeEventListener("keyup", this.boundHandlers.keyUp);
this.canvas.removeEventListener("paste", this.boundHandlers.paste);
this.canvas.removeEventListener("copy", this.boundHandlers.copy);
this.canvas.removeEventListener("focus", this.boundHandlers.focus);
this.canvas.removeEventListener("blur", this.boundHandlers.blur);
this.canvas.removeEventListener("click", this.boundHandlers.click);
document.removeEventListener("keydown", this.boundHandlers.globalKeyDown);
this.releaseAllKeys();
this.isActive = false;
}
}
if (typeof window !== "undefined") {
(window as any).IronRDPInputHandler = IronRDPInputHandler;
}

View File

@@ -0,0 +1,450 @@
export interface IronRDPModule {
SessionBuilder: new () => SessionBuilder;
DesktopSize: new (width: number, height: number) => DesktopSize;
ClipboardData?: new () => ClipboardData;
default?: () => Promise<void>;
init?: () => Promise<void>;
}
interface DesktopSize {
width: number;
height: number;
}
interface SessionBuilder {
username(user: string): SessionBuilder;
password(pwd: string): SessionBuilder;
destination(dest: string): SessionBuilder;
serverDomain(domain: string): SessionBuilder;
desktopSize(size: DesktopSize): SessionBuilder;
renderCanvas(canvas: HTMLCanvasElement): SessionBuilder;
proxyAddress(url: string): SessionBuilder;
authToken(token: string): SessionBuilder;
setCursorStyleCallback(cb: (style: string) => void): void;
setCursorStyleCallbackContext(ctx: unknown): void;
remoteClipboardChangedCallback(cb: (data: ClipboardData) => void): void;
forceClipboardUpdateCallback(cb: () => void): void;
connect(): Promise<RDPSession>;
}
export interface RDPSession {
run(): Promise<TerminationInfo>;
shutdown(): void;
sendInput(input: unknown): void;
onClipboardPaste?(content: ClipboardData): Promise<void>;
inputHandler?: IronRDPInputHandler;
}
interface TerminationInfo {
reason(): string;
}
interface ClipboardData {
items(): ClipboardItem[];
addText(mimeType: string, text: string): void;
addBinary(mimeType: string, binary: Uint8Array): void;
isEmpty(): boolean;
}
interface ClipboardItem {
mimeType(): string;
value(): string;
}
interface RDPConfig {
username: string;
password: string;
domain?: string;
width: number;
height: number;
enable_tls: boolean;
enable_credssp: boolean;
enable_nla: boolean;
}
declare global {
interface Window {
IronRDPBridge: IronRDPWASMBridge;
IronRDPInputHandler?: new (
ironrdp: IronRDPModule,
session: RDPSession,
canvas: HTMLCanvasElement,
) => IronRDPInputHandler;
initializeIronRDP: () => Promise<boolean>;
onIronRDPReady?: () => void;
createRDCleanPathProxy?: (
hostname: string,
port: number,
) => Promise<string>;
}
}
interface IronRDPInputHandler {
destroy(): void;
}
const IRON_RDP_PKG = "/ironrdp-pkg/ironrdp_web.js";
export class IronRDPWASMBridge {
private ironrdp: IronRDPModule | null = null;
private initialized = false;
private sessions = new Map<string, RDPSession>();
private lastClipboardContent = "";
private clipboardEventListeners: (() => void)[] = [];
// Expose clipboard sync method for input handler
async initialize(): Promise<void> {
if (this.initialized) return;
try {
// @ts-ignore - Dynamic import from public directory
const ironrdpModule = (await import(
/* webpackIgnore: true */ IRON_RDP_PKG
)) as IronRDPModule;
try {
if (ironrdpModule.default) {
await ironrdpModule.default();
}
} catch (e) {
if (ironrdpModule.init) {
await ironrdpModule.init();
}
}
this.ironrdp = ironrdpModule;
this.initialized = true;
if (window.onIronRDPReady) {
window.onIronRDPReady();
}
} catch (error) {
console.error("Failed to load IronRDP WASM:", error);
this.initialized = false;
}
}
async connect(
hostname: string,
port: number,
username: string,
password: string,
canvas: HTMLCanvasElement,
enableClipboard = true,
netbirdClient?: {
createRDPProxy: (hostname: string, port: string) => Promise<string>;
},
): Promise<string> {
if (!this.initialized) {
await this.initialize();
}
if (!this.ironrdp) {
throw new Error("IronRDP module not loaded");
}
const sessionId = `${hostname}:${port}_${Date.now()}`;
try {
const config: RDPConfig = {
username,
password,
domain: "",
width: canvas.width || 1024,
height: canvas.height || 768,
enable_tls: true,
enable_credssp: true,
enable_nla: true,
};
const builder = new this.ironrdp.SessionBuilder();
builder
.username(username)
.password(password)
.destination(`${hostname}:${port}`);
if (config.domain) {
builder.serverDomain(config.domain);
}
const desktopSize = new this.ironrdp.DesktopSize(
config.width,
config.height,
);
builder.desktopSize(desktopSize);
if (canvas) {
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
}
builder.renderCanvas(canvas);
}
builder.setCursorStyleCallback((style: string) => {});
builder.setCursorStyleCallbackContext(null);
if (enableClipboard) {
this.setupClipboard(builder);
}
// RDCleanPath proxy is required for IronRDP
if (!netbirdClient || !netbirdClient.createRDPProxy) {
throw new Error("NetBird client with RDP proxy support is required");
}
const proxyURL = await netbirdClient.createRDPProxy(
hostname,
port.toString(),
);
builder.proxyAddress(proxyURL);
builder.authToken("");
const session = await builder.connect();
this.sessions.set(sessionId, session);
if (canvas) {
this.attachInputHandler(session, canvas);
}
if (enableClipboard) {
this.startClipboardEventListeners();
}
this.startSession(session, sessionId);
return sessionId;
} catch (error) {
console.error(`IronRDP connection failed:`, error);
this.logIronError(error);
throw error;
}
}
private setupClipboard(builder: SessionBuilder): void {
if (!this.ironrdp?.ClipboardData) {
console.warn("ClipboardData class not available in IronRDP module");
return;
}
builder.remoteClipboardChangedCallback((clipboardData: ClipboardData) => {
this.handleRemoteClipboard(clipboardData);
});
builder.forceClipboardUpdateCallback(() => {
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()
.then((termInfo) => {
this.cleanupSession(session, sessionId);
})
.catch((err) => {
console.error("IronRDP session error:", err);
this.cleanupSession(session, sessionId);
throw Error(err);
});
}
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
if (this.sessions.size === 0) {
this.stopClipboardEventListeners();
}
}
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());
}
if (ironError.kind) {
const errorKind = ironError.kind();
const errorKindNames = [
"General",
"WrongPassword",
"LogonFailure",
"AccessDenied",
"RDCleanPath",
"ProxyConnect",
"NegotiationFailure",
];
const errorKindName = errorKindNames[errorKind] || "Unknown";
console.error("IronRDP error kind:", errorKindName, `(${errorKind})`);
}
} catch (e) {
console.error("Could not extract IronError details:", e);
}
}
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();
}
this.sessions.delete(sessionId);
// Stop clipboard event listeners if no active sessions
if (this.sessions.size === 0) {
this.stopClipboardEventListeners();
}
}
private handleRemoteClipboard(clipboardData: ClipboardData): void {
if (!navigator.clipboard?.writeText) {
console.warn("Browser clipboard API not available");
return;
}
if (!clipboardData.items) {
console.error("clipboardData.items() method not found");
return;
}
const items = clipboardData.items();
if (items.length === 0) return;
for (const item of items) {
const mimeType = item.mimeType();
const value = item.value();
if (mimeType !== "text/plain") {
continue;
}
navigator.clipboard
.writeText(value)
.then(() => {
//this.showClipboardNotification("Clipboard updated from RDP");
})
.catch((err) => {
console.error("Failed to copy to browser clipboard:", err);
this.fallbackClipboardCopy(value);
});
return; // Only handle first text item
}
}
private fallbackClipboardCopy(text: string): void {
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const success = document.execCommand("copy");
document.body.removeChild(textarea);
if (success) {
//this.showClipboardNotification("Clipboard updated from RDP");
}
} catch (err) {
console.error("Fallback clipboard error:", err);
}
}
private async handleLocalClipboardRequest(): Promise<void> {
if (!navigator.clipboard?.readText) {
console.warn("Browser clipboard read API not available");
return;
}
try {
const clipboardText = await navigator.clipboard.readText();
if (clipboardText && clipboardText !== this.lastClipboardContent) {
await this.sendClipboardToRDP(clipboardText);
this.lastClipboardContent = clipboardText;
}
} catch (err) {
console.warn("Could not read from clipboard:", err);
}
}
private async sendClipboardToRDP(text: string): Promise<void> {
if (!this.ironrdp?.ClipboardData) return;
for (const [sessionId, session] of this.sessions) {
try {
const clipboardData = new this.ironrdp.ClipboardData();
clipboardData.addText("text/plain", text);
if (session.onClipboardPaste) {
await session.onClipboardPaste(clipboardData);
}
//this.showClipboardNotification("Clipboard sent to RDP");
} catch (err) {
console.error("Failed to send clipboard to RDP:", err);
}
}
}
private startClipboardEventListeners(): void {
if (this.clipboardEventListeners.length > 0) return;
// Listen for keyboard shortcuts (Ctrl+C, Ctrl+V, Ctrl+X)
const handleKeyboardShortcut = async (event: KeyboardEvent) => {
if (
(event.ctrlKey || event.metaKey) &&
["c", "x", "v"].includes(event.key.toLowerCase())
) {
// For copy/cut operations, check clipboard after delay
if (["c", "x"].includes(event.key.toLowerCase())) {
setTimeout(async () => {
await this.checkAndSendClipboard();
}, 100);
}
// For paste, check immediately to ensure up-to-date content
else if (event.key.toLowerCase() === "v") {
await this.checkAndSendClipboard();
}
}
};
// Listen for clipboard events (more reliable when available)
const handleClipboardChange = async () => {
await this.checkAndSendClipboard();
};
// Listen for focus events - check clipboard when window regains focus
const handleFocus = async () => {
await this.checkAndSendClipboard();
};
// Add event listeners
document.addEventListener("keydown", handleKeyboardShortcut);
document.addEventListener("copy", handleClipboardChange);
document.addEventListener("cut", handleClipboardChange);
window.addEventListener("focus", handleFocus);
// Store cleanup functions
this.clipboardEventListeners = [
() => document.removeEventListener("keydown", handleKeyboardShortcut),
() => document.removeEventListener("copy", handleClipboardChange),
() => document.removeEventListener("cut", handleClipboardChange),
() => window.removeEventListener("focus", handleFocus),
];
}
private stopClipboardEventListeners(): void {
this.clipboardEventListeners.forEach((cleanup) => cleanup());
this.clipboardEventListeners = [];
}
public async checkAndSendClipboard(): Promise<void> {
if (!navigator.clipboard?.readText) return;
if (!/Chrome/.test(navigator.userAgent)) {
return;
}
try {
const clipboardText = await navigator.clipboard.readText();
if (clipboardText && clipboardText !== this.lastClipboardContent) {
await this.sendClipboardToRDP(clipboardText);
this.lastClipboardContent = clipboardText;
}
} catch (err) {
// Ignore clipboard read errors - might be due to focus/permission issues
}
}
}
if (typeof window !== "undefined") {
window.IronRDPBridge = new IronRDPWASMBridge();
window.initializeIronRDP = async function (): Promise<boolean> {
try {
await window.IronRDPBridge.initialize();
return true;
} catch (error) {
console.error("Failed to initialize IronRDP:", error);
return false;
}
};
}

View File

@@ -0,0 +1,397 @@
/**
* RDP Certificate Handler
* Handles X.509 certificate validation and user acceptance for RDP connections
*/
export interface CertificateInfo {
raw?: Uint8Array;
fingerprint: string;
hostname: string;
subject?: string;
issuer?: string;
validFrom?: Date;
validTo?: Date;
serialNumber?: string;
keySize?: number;
}
interface TrustedCertificate {
fingerprint: string;
hostname: string;
addedAt: string;
subject?: string;
}
export interface RDCleanPathResponse {
ServerAddr?: string;
ServerCertChain?: Uint8Array[];
CertificateInfo?: CertificateInfo;
}
export interface CertificateHandler {
validateCertificate(certInfo: CertificateInfo, hostname?: string): Promise<boolean>;
handleRDCleanPathResponse(response: RDCleanPathResponse): Promise<boolean>;
}
export class RDPCertificateHandler implements CertificateHandler {
private readonly STORAGE_KEY = 'netbird-rdp-trusted-certs';
private modalElement: HTMLElement | null = null;
/**
* Handle RDCleanPath response containing server certificates
*/
async handleRDCleanPathResponse(response: RDCleanPathResponse): Promise<boolean> {
if (!response.ServerCertChain || response.ServerCertChain.length === 0) {
console.error('No certificate chain provided - rejecting connection for security');
return false;
}
const serverAddr = response.ServerAddr || 'unknown';
const hostname = serverAddr.split(':')[0];
const certBytes = response.ServerCertChain[0];
try {
// Check if response already has parsed certificate info from Go proxy
if (response.CertificateInfo) {
return await this.validateCertificate(response.CertificateInfo, hostname);
}
// Fallback to parsing the raw certificate
const certInfo = await this.parseCertificate(certBytes, hostname);
return await this.validateCertificate(certInfo, hostname);
} catch (error) {
console.error('Certificate validation error:', error);
return await this.promptRawCertificateAcceptance(certBytes, hostname);
}
}
/**
* Parse X.509 certificate bytes to extract relevant information
* Note: For proper X.509 parsing, the Go proxy should provide parsed certificate info
*/
async parseCertificate(certBytes: Uint8Array, hostname: string): Promise<CertificateInfo> {
const fingerprint = await this.calculateFingerprint(certBytes);
// Basic certificate info - actual parsing should be done by Go proxy
const certInfo: CertificateInfo = {
raw: certBytes,
fingerprint: fingerprint,
hostname: hostname
};
// Try to extract basic info from certificate if not provided by proxy
// This is a fallback - proper X.509 parsing should be done server-side
const certString = new TextDecoder('latin1').decode(certBytes);
const cnMatch = certString.match(/CN=([^,\0]+)/);
if (cnMatch) {
certInfo.subject = cnMatch[0];
}
return certInfo;
}
/**
* Extract serial number from certificate bytes
* Note: This is a placeholder - actual serial number extraction requires proper ASN.1 parsing
*/
private extractSerialNumber(certBytes: Uint8Array): string | undefined {
// Serial number extraction should be done by the Go proxy
// This is just a fallback that won't work reliably
return undefined;
}
/**
* Calculate SHA-256 fingerprint of certificate
*/
async calculateFingerprint(certBytes: Uint8Array): Promise<string> {
const hashBuffer = await crypto.subtle.digest('SHA-256', certBytes);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray
.map(b => b.toString(16).padStart(2, '0'))
.join(':')
.toUpperCase();
}
/**
* Validate certificate against stored trust database
*/
async validateCertificate(certInfo: CertificateInfo, hostname?: string): Promise<boolean> {
const host = hostname || certInfo.hostname;
const trustedCerts = this.loadTrustedCerts();
if (trustedCerts[host]) {
const stored = trustedCerts[host];
if (stored.fingerprint === certInfo.fingerprint) {
return true;
} else {
console.warn(`Certificate for ${host} has changed!`);
return await this.promptCertificateChange(host, certInfo, stored);
}
}
console.log(`New certificate for ${host} - requesting user acceptance`);
return await this.promptUserAcceptance(host, certInfo);
}
/**
* Load trusted certificates from storage
*/
private loadTrustedCerts(): Record<string, TrustedCertificate> {
try {
const stored = localStorage.getItem(this.STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch (error) {
console.error('Failed to load trusted certificates:', error);
return {};
}
}
/**
* Save trusted certificate to storage
*/
private saveTrustedCert(hostname: string, certInfo: CertificateInfo): void {
try {
const trustedCerts = this.loadTrustedCerts();
trustedCerts[hostname] = {
fingerprint: certInfo.fingerprint,
hostname: hostname,
addedAt: new Date().toISOString(),
subject: certInfo.subject
};
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(trustedCerts));
} catch (error) {
console.error('Failed to save trusted certificate:', error);
}
}
/**
* Prompt user to accept a new certificate
*/
private async promptUserAcceptance(hostname: string, certInfo: CertificateInfo): Promise<boolean> {
return new Promise((resolve) => {
const modal = this.createCertificateModal(hostname, certInfo, false);
const acceptBtn = modal.querySelector('#cert-accept') as HTMLButtonElement;
const rejectBtn = modal.querySelector('#cert-reject') as HTMLButtonElement;
const rememberCheck = modal.querySelector('#cert-remember') as HTMLInputElement;
acceptBtn.onclick = () => {
const remember = rememberCheck.checked;
if (remember) {
this.saveTrustedCert(hostname, certInfo);
}
this.closeModal();
resolve(true);
};
rejectBtn.onclick = () => {
this.closeModal();
resolve(false);
};
});
}
/**
* Prompt user when certificate has changed
*/
private async promptCertificateChange(
hostname: string,
newCert: CertificateInfo,
oldCert: TrustedCertificate
): Promise<boolean> {
return new Promise((resolve) => {
const modal = this.createCertificateModal(hostname, newCert, true);
// Add warning about certificate change
const warningDiv = modal.querySelector('.cert-warning') as HTMLElement;
warningDiv.innerHTML = `
<strong>⚠️ Certificate has changed!</strong><br>
<small>Previous fingerprint: ${oldCert.fingerprint.substring(0, 32)}...</small>
`;
const acceptBtn = modal.querySelector('#cert-accept') as HTMLButtonElement;
const rejectBtn = modal.querySelector('#cert-reject') as HTMLButtonElement;
const rememberCheck = modal.querySelector('#cert-remember') as HTMLInputElement;
acceptBtn.onclick = () => {
const remember = rememberCheck.checked;
if (remember) {
this.saveTrustedCert(hostname, newCert);
}
this.closeModal();
resolve(true);
};
rejectBtn.onclick = () => {
this.closeModal();
resolve(false);
};
});
}
/**
* Prompt for raw certificate acceptance when parsing fails
*/
private async promptRawCertificateAcceptance(certBytes: Uint8Array, hostname: string): Promise<boolean> {
const fingerprint = await this.calculateFingerprint(certBytes);
const certInfo: CertificateInfo = {
raw: certBytes,
fingerprint: fingerprint,
hostname: hostname,
subject: 'Unable to parse',
issuer: 'Unable to parse'
};
return this.promptUserAcceptance(hostname, certInfo);
}
/**
* Create certificate acceptance modal
*/
private createCertificateModal(hostname: string, certInfo: CertificateInfo, isChange: boolean): HTMLElement {
// Remove any existing modal
this.closeModal();
const modal = document.createElement('div');
modal.className = 'rdp-cert-modal';
modal.innerHTML = `
<div class="rdp-cert-overlay"></div>
<div class="rdp-cert-dialog">
<h2>RDP Certificate Verification</h2>
<div class="cert-warning" style="color: #ff9800; margin-bottom: 15px;"></div>
<p>The server <strong>${hostname}</strong> is presenting a certificate:</p>
<div class="cert-details">
<table>
<tr><td><strong>Subject:</strong></td><td>${certInfo.subject || 'Unknown'}</td></tr>
<tr><td><strong>Issuer:</strong></td><td>${certInfo.issuer || 'Unknown'}</td></tr>
${certInfo.serialNumber ? `<tr><td><strong>Serial:</strong></td><td style="font-family: monospace; font-size: 0.9em;">${certInfo.serialNumber}</td></tr>` : ''}
<tr><td><strong>SHA-256:</strong></td><td style="font-family: monospace; font-size: 0.9em;">
${certInfo.fingerprint}</td></tr>
</table>
</div>
<div class="cert-question">
<p>Do you trust this certificate?</p>
<label>
<input type="checkbox" id="cert-remember" checked>
Remember this certificate for future connections
</label>
</div>
<div class="cert-buttons">
<button id="cert-reject" class="cert-btn cert-btn-reject">Reject</button>
<button id="cert-accept" class="cert-btn cert-btn-accept">Accept</button>
</div>
</div>
`;
// Add styles if not already present
if (!document.querySelector('#rdp-cert-styles')) {
const styles = document.createElement('style');
styles.id = 'rdp-cert-styles';
styles.textContent = this.getModalStyles();
document.head.appendChild(styles);
}
document.body.appendChild(modal);
this.modalElement = modal;
return modal;
}
/**
* Close the certificate modal
*/
private closeModal(): void {
if (this.modalElement) {
document.body.removeChild(this.modalElement);
this.modalElement = null;
}
}
/**
* Get modal CSS styles
*/
private getModalStyles(): string {
return `
.rdp-cert-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10000;
}
.rdp-cert-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
}
.rdp-cert-dialog {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 8px;
padding: 25px;
max-width: 600px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.rdp-cert-dialog h2 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #0078d4;
padding-bottom: 10px;
}
.cert-details {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 15px;
margin: 15px 0;
}
.cert-details table {
width: 100%;
border-collapse: collapse;
}
.cert-details td {
padding: 5px 10px;
vertical-align: top;
}
.cert-details td:first-child {
width: 100px;
text-align: right;
padding-right: 10px;
}
.cert-question {
margin: 20px 0;
}
.cert-question label {
display: flex;
align-items: center;
cursor: pointer;
}
.cert-question input {
margin-right: 8px;
}
.cert-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
.cert-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
.cert-btn-accept {
background: #0078d4;
color: white;
}
.cert-btn-accept:hover {
background: #106ebe;
}
.cert-btn-reject {
background: #e0e0e0;
color: #333;
}
.cert-btn-reject:hover {
background: #d0d0d0;
}
`;
}
/**
* Clear all trusted certificates
*/
clearTrustedCerts(): void {
localStorage.removeItem(this.STORAGE_KEY);
console.log('Cleared all trusted RDP certificates');
}
/**
* Get list of trusted certificates
*/
getTrustedCerts(): TrustedCertificate[] {
const trustedCerts = this.loadTrustedCerts();
return Object.values(trustedCerts);
}
}
// Export as global for compatibility
declare global {
interface Window {
RDPCertificateHandler: typeof RDPCertificateHandler;
}
}
if (typeof window !== 'undefined') {
window.RDPCertificateHandler = RDPCertificateHandler;
}

View File

@@ -0,0 +1,320 @@
import { useCallback, useState } from "react";
export interface CertificateInfo {
raw?: Uint8Array;
fingerprint: string;
hostname: string;
subject?: string;
issuer?: string;
validFrom?: Date;
validTo?: Date;
serialNumber?: string;
keySize?: number;
}
interface TrustedCertificate {
fingerprint: string;
hostname: string;
addedAt: string;
subject?: string;
}
export interface RDCleanPathResponse {
ServerAddr?: string;
ServerCertChain?: Uint8Array[];
CertificateInfo?: CertificateInfo;
}
export interface CertificatePromptInfo {
hostname: string;
certificate: CertificateInfo;
isChange: boolean;
previousCertificate?: TrustedCertificate;
}
export interface CertificateValidationResult {
isValid: boolean;
needsUserConfirmation: boolean;
promptInfo?: CertificatePromptInfo;
}
const STORAGE_KEY = "netbird-rdp-trusted-certs";
export const useRDPCertificateHandler = () => {
const [isValidating, setIsValidating] = useState(false);
const calculateFingerprint = useCallback(
async (certBytes: Uint8Array): Promise<string> => {
try {
const hashBuffer = await crypto.subtle.digest("SHA-256", certBytes);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const fingerprint = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join(":")
.toUpperCase();
return fingerprint;
} catch (error) {
return "FINGERPRINT_CALCULATION_FAILED";
}
},
[],
);
const parseCertificate = useCallback(
async (
certBytes: Uint8Array,
hostname: string,
): Promise<CertificateInfo> => {
const fingerprint = await calculateFingerprint(certBytes);
const certInfo: CertificateInfo = {
raw: certBytes,
fingerprint,
hostname,
};
try {
const certString = new TextDecoder("latin1").decode(certBytes);
// Parse subject (CN)
const cnMatch = certString.match(/CN=([^,\0\x00-\x1f]+)/);
if (cnMatch) {
certInfo.subject = `CN=${cnMatch[1].trim()}`;
}
// Parse issuer - look for issuer field
const issuerMatch = certString.match(
/(?:issuer|Issuer).*?CN=([^,\0\x00-\x1f]+)/i,
);
if (issuerMatch) {
certInfo.issuer = `CN=${issuerMatch[1].trim()}`;
} else {
// Fallback: look for second CN occurrence (often issuer)
const cnMatches = [...certString.matchAll(/CN=([^,\0\x00-\x1f]+)/g)];
if (cnMatches.length > 1) {
certInfo.issuer = `CN=${cnMatches[1][1].trim()}`;
}
}
// Estimate key size based on certificate structure and length
if (certBytes.length > 100) {
// Look for RSA signature patterns or use length heuristic
if (certBytes.length > 1400) {
certInfo.keySize = 2048;
} else if (certBytes.length > 1000) {
certInfo.keySize = 1024;
} else if (certBytes.length > 600) {
certInfo.keySize = 1024;
} else {
certInfo.keySize = 512;
}
}
// Try to parse serial number (look for sequence of hex bytes)
const serialMatch = certString.match(
/[\x02][\x01-\x10]([\x00-\xff]{1,16})/,
);
if (serialMatch && serialMatch[1]) {
const serialBytes = Array.from(serialMatch[1], (char) =>
char.charCodeAt(0),
);
certInfo.serialNumber = serialBytes
.map((b) => b.toString(16).padStart(2, "0"))
.join(":")
.toUpperCase();
}
// Try to parse validity dates (basic ASN.1 time format)
// Look for UTCTime or GeneralizedTime patterns
const timePattern =
/[\x17\x18][\x0d\x0f](\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z?/g;
const timeMatches = [...certString.matchAll(timePattern)];
if (timeMatches.length >= 2) {
const parseTime = (match: RegExpMatchArray) => {
let year = parseInt(match[1]);
// Handle 2-digit years (UTCTime format)
if (year < 50) year += 2000;
else if (year < 100) year += 1900;
const month = parseInt(match[2]) - 1; // JS months are 0-based
const day = parseInt(match[3]);
const hour = parseInt(match[4]);
const minute = parseInt(match[5]);
const second = parseInt(match[6]);
return new Date(year, month, day, hour, minute, second);
};
certInfo.validFrom = parseTime(timeMatches[0]);
certInfo.validTo = parseTime(timeMatches[1]);
}
} catch (error) {
console.warn("Failed to parse certificate details:", error);
}
return certInfo;
},
[calculateFingerprint],
);
const getTrustedCerts = useCallback((): Record<
string,
TrustedCertificate
> => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch (error) {
console.error("Failed to load trusted certificates:", error);
return {};
}
}, []);
const saveTrustedCert = useCallback(
(hostname: string, certInfo: CertificateInfo): void => {
try {
const trustedCerts = getTrustedCerts();
trustedCerts[hostname] = {
fingerprint: certInfo.fingerprint,
hostname,
addedAt: new Date().toISOString(),
subject: certInfo.subject,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(trustedCerts));
} catch (error) {
console.error("Failed to save trusted certificate:", error);
}
},
[getTrustedCerts],
);
const validateCertificate = useCallback(
async (
certInfo: CertificateInfo,
hostname?: string,
): Promise<CertificateValidationResult> => {
const host = hostname || certInfo.hostname;
const trustedCerts = getTrustedCerts();
const stored = trustedCerts[host];
if (stored) {
if (stored.fingerprint === certInfo.fingerprint) {
return { isValid: true, needsUserConfirmation: false };
}
console.warn(`Certificate for ${host} has changed!`);
return {
isValid: false,
needsUserConfirmation: true,
promptInfo: {
hostname: host,
certificate: certInfo,
isChange: true,
previousCertificate: stored,
},
};
}
return {
isValid: false,
needsUserConfirmation: true,
promptInfo: {
hostname: host,
certificate: certInfo,
isChange: false,
},
};
},
[getTrustedCerts],
);
const handleRDCleanPathResponse = useCallback(
async (
response: RDCleanPathResponse,
): Promise<CertificateValidationResult> => {
setIsValidating(true);
try {
if (!response.ServerCertChain?.length) {
return { isValid: false, needsUserConfirmation: false };
}
const serverAddr = response.ServerAddr || "unknown";
const hostname = serverAddr.split(":")[0];
const certBytes = response.ServerCertChain[0];
let certInfo: CertificateInfo;
if (response.CertificateInfo) {
certInfo = response.CertificateInfo;
// Add missing fingerprint and keySize that the server doesn't provide
if (!certInfo.fingerprint) {
certInfo.fingerprint = await calculateFingerprint(certBytes);
}
if (!certInfo.keySize && certBytes.length > 100) {
if (certBytes.length > 1400) {
certInfo.keySize = 2048;
} else if (certBytes.length > 1000) {
certInfo.keySize = 1024;
} else if (certBytes.length > 600) {
certInfo.keySize = 1024;
} else {
certInfo.keySize = 512;
}
}
} else {
try {
certInfo = await parseCertificate(certBytes, hostname);
} catch (error) {
console.error("Certificate parsing error:", error);
const fingerprint = await calculateFingerprint(certBytes);
certInfo = {
raw: certBytes,
fingerprint,
hostname,
subject: "Unable to parse",
issuer: "Unable to parse",
};
}
}
return await validateCertificate(certInfo, hostname);
} finally {
setIsValidating(false);
}
},
[parseCertificate, validateCertificate, calculateFingerprint],
);
const acceptCertificate = useCallback(
(
hostname: string,
certInfo: CertificateInfo,
remember: boolean = true,
): void => {
if (remember) {
saveTrustedCert(hostname, certInfo);
}
},
[saveTrustedCert],
);
const clearTrustedCerts = useCallback((): void => {
localStorage.removeItem(STORAGE_KEY);
}, []);
const listTrustedCerts = useCallback((): TrustedCertificate[] => {
return Object.values(getTrustedCerts());
}, [getTrustedCerts]);
return {
isValidating,
handleRDCleanPathResponse,
validateCertificate,
acceptCertificate,
clearTrustedCerts,
getTrustedCerts: listTrustedCerts,
calculateFingerprint,
parseCertificate,
};
};

View File

@@ -0,0 +1,58 @@
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
interface RDPQueryParams {
peerId: string | null;
}
export function useRDPQueryParams() {
const searchParams = useSearchParams();
const router = useRouter();
const [params, setParams] = useState<RDPQueryParams>({
peerId: null,
});
const [, setLocalQueryParams] = useLocalStorage("netbird-query-params", "");
useEffect(() => {
const peerId = searchParams.get("id");
// If all params are present in URL, use them
if (peerId) {
setParams({ peerId });
return;
}
// Otherwise, try to restore from localStorage
const storedParams = localStorage.getItem("netbird-query-params");
if (!storedParams) return;
// Handle JSON encoded strings from localStorage
let paramsString = storedParams;
if (storedParams.startsWith('"') && storedParams.endsWith('"')) {
try {
paramsString = JSON.parse(storedParams);
} catch (e) {
return;
}
}
const urlParams = new URLSearchParams(paramsString);
const storedPeerId = urlParams.get("id");
if (storedPeerId) {
const newSearchParams = new URLSearchParams();
newSearchParams.set("id", storedPeerId);
router.replace(`/peer/rdp?${newSearchParams.toString()}`);
setParams({
peerId: storedPeerId,
});
// Clear stored params after restoring
setLocalQueryParams("");
}
}, [searchParams, router]);
return params;
}

View File

@@ -0,0 +1,325 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
CertificatePromptInfo,
useRDPCertificateHandler,
} from "./useRDPCertificateHandler";
interface IronError {
message: string;
backtrace?: () => string;
}
interface RDPConfig {
hostname: string;
port: number;
username: string;
password: string;
width?: number;
height?: number;
}
export interface RDPCredentials {
username: string;
password: string;
port: number;
}
interface RDPConnection {
id: string;
disconnect: (options?: {
preserveConfig?: boolean;
preserveCertificateState?: boolean;
}) => void;
}
export enum RDPStatus {
DISCONNECTED = 0,
CONNECTED = 1,
CONNECTING = 2,
}
export const RDP_DOCS_LINK = "https://docs.netbird.io/";
export const useRemoteDesktop = (client: any) => {
const [status, setStatus] = useState(RDPStatus.DISCONNECTED);
const [config, setConfig] = useState<RDPConfig | null>(null);
const [error, setError] = useState("");
const [pendingCertificate, setPendingCertificate] =
useState<CertificatePromptInfo | null>(null);
const session = useRef<RDPConnection | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isResizing, setIsResizing] = useState(false);
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastConnectedConfigRef = useRef<RDPConfig | null>(null);
const certificatePromiseRef = useRef<{
resolve: (value: boolean) => void;
reject: (reason?: any) => void;
} | null>(null);
const { handleRDCleanPathResponse, acceptCertificate } =
useRDPCertificateHandler();
const certificateAccepted = useRef(false);
/**
* Reset the RDP state, optionally preserving config and/or certificate state
*/
const resetState = useCallback(
(
options: {
preserveConfig?: boolean;
preserveCertificateState?: boolean;
} = {},
) => {
session.current = null;
setStatus(RDPStatus.DISCONNECTED);
if (!options.preserveConfig) {
setConfig(null);
}
setError("");
if (!options.preserveCertificateState) {
setPendingCertificate(null);
certificatePromiseRef.current = null;
}
},
[],
);
/**
* Set up the global RDPCertificateHandler to intercept certificate prompts
*/
const setupCertificateHandler = useCallback(() => {
const originalHandler = (window as any).RDPCertificateHandler;
(window as any).RDPCertificateHandler = function () {
this.handleRDCleanPathResponse = async (response: any) => {
const result = await handleRDCleanPathResponse(response);
if (result.isValid) {
return true;
}
if (result.needsUserConfirmation && result.promptInfo) {
setPendingCertificate(result.promptInfo);
return new Promise((resolve, reject) => {
certificatePromiseRef.current = { resolve, reject };
});
}
return false;
};
if (originalHandler?.prototype) {
Object.getOwnPropertyNames(originalHandler.prototype).forEach(
(name) => {
if (
name !== "constructor" &&
name !== "handleRDCleanPathResponse"
) {
this[name] = originalHandler.prototype[name];
}
},
);
}
};
return originalHandler;
}, [handleRDCleanPathResponse]);
/**
* Establish an RDP connection
*/
const connect = useCallback(
async (rdpConfig: RDPConfig): Promise<RDPStatus> => {
if (status === RDPStatus.CONNECTING) return status;
setStatus(RDPStatus.CONNECTING);
setConfig(rdpConfig);
setError("");
try {
if (!canvasRef.current) {
throw new Error("Canvas not available for RDP rendering");
}
if (!client?.ironRDPBridge || !client?.initializeIronRDP) {
throw new Error("IronRDP components not available from client");
}
const canvas = canvasRef.current;
canvas.width = rdpConfig.width || 1024;
canvas.height = rdpConfig.height || 768;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
}
const initialized = await client.initializeIronRDP();
if (!initialized) {
throw new Error("Failed to initialize IronRDP");
}
const originalHandler = setupCertificateHandler();
try {
const sessionId = await client.ironRDPBridge.connect(
rdpConfig.hostname,
rdpConfig.port,
rdpConfig.username,
rdpConfig.password,
canvas,
true,
client.client,
);
session.current = {
id: sessionId,
disconnect: (options = {}) => {
try {
if (client.ironRDPBridge && sessionId) {
client.ironRDPBridge.disconnect(sessionId);
}
resetState(options);
} catch (err) {
resetState(options);
}
},
};
setStatus(RDPStatus.CONNECTED);
lastConnectedConfigRef.current = rdpConfig;
return RDPStatus.CONNECTED;
} catch (err) {
const ironError = err as IronError;
const errorMessage = ironError.backtrace
? ironError.backtrace()
: "RDP connection failed";
setError(errorMessage);
resetState();
throw Error(errorMessage);
} finally {
(window as any).RDPCertificateHandler = originalHandler;
}
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "RDP connection failed";
setError(errorMessage);
resetState();
throw Error(errorMessage);
}
},
[client, status, setupCertificateHandler, resetState],
);
/**
* Accept the pending certificate prompt
*/
const acceptCertificatePrompt = useCallback(
(remember: boolean = false) => {
if (!pendingCertificate || !certificatePromiseRef.current) return;
acceptCertificate(
pendingCertificate.hostname,
pendingCertificate.certificate,
remember,
);
certificatePromiseRef.current.resolve(true);
setPendingCertificate(null);
certificatePromiseRef.current = null;
certificateAccepted.current = true;
},
[pendingCertificate, acceptCertificate],
);
/**
* Reject the pending certificate prompt
*/
const rejectCertificatePrompt = useCallback(() => {
if (!certificatePromiseRef.current) return;
certificatePromiseRef.current.resolve(false);
setPendingCertificate(null);
certificatePromiseRef.current = null;
}, []);
/**
* Handle window resize events - reconnect with new dimensions
*/
useEffect(() => {
const handleResize = () => {
// Only handle resize if we're connected and have a previous config
if (status !== RDPStatus.CONNECTED || !lastConnectedConfigRef.current) {
return;
}
// Clear any existing timeout
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
setIsResizing(true);
// Debounce resize handling for 1 second
resizeTimeoutRef.current = setTimeout(async () => {
try {
// Disconnect current session
if (session.current) {
session.current.disconnect({
preserveConfig: true,
preserveCertificateState: true,
});
}
// Wait for cleanup
await new Promise((resolve) => setTimeout(resolve, 300));
// Reconnect with new dimensions
const newConfig = {
...lastConnectedConfigRef.current!,
width: window.innerWidth,
height: window.innerHeight,
};
await connect(newConfig);
} finally {
setIsResizing(false);
}
}, 1000);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
};
}, [status, connect]);
/**
* Auto accept certificate if previously accepted (for reconnects)
*/
useEffect(() => {
if (pendingCertificate && certificateAccepted.current) {
acceptCertificatePrompt();
}
}, [acceptCertificatePrompt, pendingCertificate]);
return {
connect,
status,
config,
error,
isResizing,
session: session.current,
canvasRef,
// Certificate handling
pendingCertificate,
acceptCertificatePrompt,
rejectCertificatePrompt,
};
};

View File

@@ -0,0 +1,206 @@
/**
* WebSocket Proxy System for RDCleanPath connections
* Provides WebSocket interface that routes through RDCleanPath proxy
*/
import type { CertificateHandler, CertificateInfo, RDCleanPathResponse } from './rdp-certificate-handler';
declare global {
interface Window {
handleRDCleanPathWebSocket?: (ws: RDCleanPathProxyWebSocket, proxyID: string) => void;
createRDCleanPathProxy?: (hostname: string, port: number) => Promise<string>;
getRDCleanPathCertificate?: (proxyID: string) => Promise<RDCleanPathResponse | null>;
RDCleanPathProxyWebSocket?: typeof RDCleanPathProxyWebSocket;
sendToRDCleanPathProxy?: (proxyID: string, data: ArrayBuffer | Uint8Array | string) => void;
closeRDCleanPathProxy?: (proxyID: string) => void;
}
}
abstract class BaseWebSocketProxy extends EventTarget {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
url: string;
readyState: number;
readonly protocol: string = '';
readonly extensions: string = '';
readonly bufferedAmount: number = 0;
readonly binaryType: BinaryType = 'blob';
onopen: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
protected messageQueue: any[] = [];
constructor(url: string) {
super();
this.url = url;
this.readyState = BaseWebSocketProxy.CONNECTING;
}
get CONNECTING() { return BaseWebSocketProxy.CONNECTING; }
get OPEN() { return BaseWebSocketProxy.OPEN; }
get CLOSING() { return BaseWebSocketProxy.CLOSING; }
get CLOSED() { return BaseWebSocketProxy.CLOSED; }
protected emitOpen(): void {
this.readyState = BaseWebSocketProxy.OPEN;
const event = new Event('open');
this.onopen?.(event);
this.dispatchEvent(event);
}
protected emitError(error: any): void {
const event = new Event('error');
this.onerror?.(event);
this.dispatchEvent(event);
}
protected emitMessage(data: any): void {
const event = new MessageEvent('message', { data });
this.onmessage?.(event);
this.dispatchEvent(event);
}
protected emitClose(code = 1000, reason = ''): void {
this.readyState = BaseWebSocketProxy.CLOSED;
const event = new CloseEvent('close', { code, reason, wasClean: code === 1000 });
this.onclose?.(event);
this.dispatchEvent(event);
}
abstract send(data: ArrayBuffer | Uint8Array | string | Blob | ArrayBufferView): void;
abstract close(code?: number, reason?: string): void;
}
export class RDCleanPathProxyWebSocket extends BaseWebSocketProxy {
private proxyID: string;
private certificateHandler: CertificateHandler | null;
onGoMessage?: (data: Uint8Array) => void;
onGoClose?: () => void;
onCertificateRequest?: (certData: RDCleanPathResponse) => Promise<boolean>;
constructor(url: string) {
super(url);
const match = url.match(/rdcleanpath\.proxy\.local\/(.+)/);
this.proxyID = match?.[1] || 'default';
if (window.RDPCertificateHandler) {
this.certificateHandler = new window.RDPCertificateHandler();
} else {
this.certificateHandler = null;
}
this.onCertificateRequest = async (certData) => {
return this.validateCertificate(certData);
};
void this._connect();
}
private async _connect(): Promise<void> {
try {
const handler = (window as any)[`handleRDCleanPathWebSocket_${this.proxyID}`];
if (!handler) {
throw new Error(`RDCleanPath WebSocket handler not available for proxy ${this.proxyID}`);
}
handler(this);
this.emitOpen();
} catch (error) {
console.error('RDCleanPath WebSocket connection failed:', error);
this.emitError(error);
this.emitClose(1006, error instanceof Error ? error.message : 'Connection failed');
}
}
protected _sendInternal(data: Uint8Array): void {
if (this.onGoMessage) {
this.onGoMessage(data);
} else {
console.warn('onGoMessage not set for proxy', this.proxyID);
}
}
send(data: ArrayBuffer | Uint8Array | string | Blob | ArrayBufferView): void {
if (this.readyState === BaseWebSocketProxy.CONNECTING) {
this.messageQueue.push(data);
return;
}
if (this.readyState !== BaseWebSocketProxy.OPEN) {
throw new Error('WebSocket is not open');
}
// Convert all data types to Uint8Array
if (data instanceof Blob) {
const reader = new FileReader();
reader.onload = () => {
this._sendInternal(new Uint8Array(reader.result as ArrayBuffer));
};
reader.readAsArrayBuffer(data);
} else if (typeof data === 'string') {
const encoder = new TextEncoder();
this._sendInternal(encoder.encode(data));
} else if (data instanceof ArrayBuffer) {
this._sendInternal(new Uint8Array(data));
} else if ('buffer' in data && data.buffer instanceof ArrayBuffer) {
const view = data as ArrayBufferView;
this._sendInternal(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
} else {
this._sendInternal(data as Uint8Array);
}
}
private async validateCertificate(certData: RDCleanPathResponse): Promise<boolean> {
if (!this.certificateHandler) {
return false;
}
try {
return await this.certificateHandler.handleRDCleanPathResponse(certData);
} catch (error) {
console.error('Certificate validation error:', error);
return false;
}
}
// Called from Go side to pass data to JavaScript
receiveFromGo(data: ArrayBuffer): void {
this.emitMessage(data);
}
// Called from Go side to close the connection
closeFromGo(code?: number, reason?: string): void {
this.emitClose(code, reason);
}
close(code = 1000, reason = ''): void {
if (this.readyState === BaseWebSocketProxy.CLOSING || this.readyState === BaseWebSocketProxy.CLOSED) {
return;
}
this.readyState = BaseWebSocketProxy.CLOSING;
if (this.onGoClose) {
this.onGoClose();
} else if (window.closeRDCleanPathProxy) {
window.closeRDCleanPathProxy(this.proxyID);
}
setTimeout(() => {
this.emitClose(code, reason);
}, 0);
}
}
export function installWebSocketProxy(): void {
const OriginalWebSocket = window.WebSocket;
window.WebSocket = new Proxy(OriginalWebSocket, {
construct(_target, args) {
const url = args[0] as string;
if (url?.includes('rdcleanpath.proxy.local')) {
window.RDCleanPathProxyWebSocket = RDCleanPathProxyWebSocket;
return new RDCleanPathProxyWebSocket(url) as unknown as WebSocket;
}
return new OriginalWebSocket(url, args[1]);
}
}) as typeof WebSocket;
}

View File

@@ -0,0 +1,76 @@
import Button from "@components/Button";
import { DropdownMenuItem } from "@components/DropdownMenu";
import { CircleHelpIcon, TerminalIcon } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { usePermissions } from "@/contexts/PermissionsProvider";
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;
isDropdown?: boolean;
};
export const SSHButton = ({ peer, isDropdown = false }: Props) => {
const [modal, setModal] = useState(false);
const { permission } = usePermissions();
const disabled =
!peer.connected || !peer.ssh_enabled || !permission.peers.update;
const hasPermission = permission.peers.update;
const os = getOperatingSystem(peer?.os);
const isWindows = os === OperatingSystem.WINDOWS;
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
const isSSHSupported = !isWindows && !isMobile;
return (
isSSHSupported && (
<>
{modal && (
<SSHCredentialsModal
open={modal}
onOpenChange={setModal}
peer={peer}
/>
)}
<div>
<SSHTooltip
disabled={!disabled}
hasPermission={hasPermission}
side={isDropdown ? "left" : "top"}
>
{isDropdown ? (
<DropdownMenuItem
onClick={() => setModal(true)}
disabled={disabled}
className={"w-full"}
>
<div className={"flex gap-3 items-center w-full"}>
<TerminalIcon size={14} className={"shrink-0"} />
SSH
</div>
</DropdownMenuItem>
) : (
<Button
variant="secondary"
size="sm"
onClick={() => setModal(true)}
disabled={disabled}
>
<TerminalIcon size={16} />
SSH
{disabled && <CircleHelpIcon size={12} />}
</Button>
)}
</SSHTooltip>
</div>
</>
)
);
};

View File

@@ -0,0 +1,155 @@
import * as React from "react";
import { useMemo, useState } from "react";
import {
Modal,
ModalClose,
ModalContent,
ModalFooter,
} from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import { Peer } from "@/interfaces/Peer";
import {
ChevronsLeftRightEllipsis,
ExternalLinkIcon,
TerminalIcon,
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 { getOperatingSystem } from "@hooks/useOperatingSystem";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import { isNativeSSHSupported } from "@utils/version";
import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
peer: Peer;
};
export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
const [username, setUsername] = useState(
getOperatingSystem(peer.os) === OperatingSystem.WINDOWS
? "Administrator"
: "root",
);
const [port, setPort] = useState(
isNativeSSHSupported(peer.version) ? "22" : "44338",
);
const userNameError = useMemo(() => {
if (username?.length === 0) return "Username cannot be empty";
}, [username]);
const portError = useMemo(() => {
const portNumber = Number(port);
const isValid =
Number.isInteger(portNumber) && portNumber > 0 && portNumber <= 65535;
if (!isValid) return "Port must be a number between 1 and 65535";
}, [port]);
const hasAnyError = useMemo(() => {
if (userNameError !== undefined) return true;
return portError !== undefined;
}, [userNameError, portError]);
const openSSHWindow = () => {
const encodedUsername = encodeURIComponent(username.trim());
const encodedPort = encodeURIComponent(port.trim());
window.open(
`peer/ssh?id=${peer.id}&user=${encodedUsername}&port=${encodedPort}`,
"_blank",
"noopener,noreferrer,width=800,height=450,left=100,top=100,location=no,toolbar=no,menubar=no,status=no",
);
onOpenChange(false);
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent maxWidthClass={"max-w-lg"}>
<ModalHeader
icon={<TerminalIcon className={"text-netbird"} size={18} />}
title={peer.name}
description={`Connect to ${peer.ip} via SSH`}
color={"netbird"}
/>
<Separator />
<div className={"px-8 py-6 flex flex-col gap-8"}>
<div className={""}>
<Label>Username & Port</Label>
<HelpText>
The username and port you will use to connect to the remote host.
</HelpText>
<div className={"flex flex-col gap-2 w-full"}>
<Input
placeholder={"root"}
value={username}
onChange={(e) => setUsername(e.target.value)}
customSuffix={`@${peer.ip}`}
data-1p-ignore
autoComplete={"off"}
error={userNameError}
errorTooltip={true}
errorTooltipPosition={"top-right"}
customPrefix={
<User2 size={16} className={"text-nb-gray-300"} />
}
/>
<Input
maxWidthClass={""}
placeholder={"22"}
min={1}
max={65535}
value={port}
type={"number"}
error={portError}
errorTooltip={true}
errorTooltipPosition={"top-right"}
onChange={(e) => setPort(e.target.value)}
customPrefix={
<ChevronsLeftRightEllipsis
size={16}
className={"text-nb-gray-300"}
/>
}
/>
</div>
</div>
</div>
<ModalFooter className={"items-center"}>
<div className={"w-full"}>
<Paragraph className={"text-sm mt-auto"}>
Learn more about
<InlineLink href={SSH_DOCS_LINK} 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"}
disabled={hasAnyError}
onClick={openSSHWindow}
>
Connect
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,55 @@
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import { ExternalLinkIcon } from "lucide-react";
import * as React from "react";
import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH";
type Props = {
disabled?: boolean;
children?: React.ReactNode;
hasPermission?: boolean;
side?: "top" | "right" | "bottom" | "left";
};
export const SSHTooltip = ({
disabled,
children,
hasPermission,
side = "top",
}: Props) => {
return (
<FullTooltip
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={SSH_DOCS_LINK} 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}
>
{children}
</FullTooltip>
);
};

View File

@@ -0,0 +1,172 @@
"use client";
import "@xterm/xterm/css/xterm.css";
import { cn } from "@utils/helpers";
import { useCallback, useEffect, useRef } from "react";
const TERMINAL_OPTIONS = {
theme: {
background: "#181a1d",
foreground: "#e4e7e9",
cursor: "#e4e7e9",
},
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
fontSize: 13,
cursorBlink: true,
convertEol: true,
scrollback: 1000,
allowTransparency: true,
};
interface TerminalWithCore {
_core?: { _isDisposed: boolean };
dispose(): void;
write(data: string | Uint8Array): void;
writeln(data: string): void;
onData(callback: (data: string) => void): void;
onResize(callback: (event: { cols: number; rows: number }) => void): void;
focus(): void;
cols: number;
rows: number;
}
interface SSHTerminalWrapperProps {
session: any;
onResize?: (cols: number, rows: number) => void;
onClose?: () => void;
className?: string;
}
export const Terminal = ({
session,
onResize,
onClose,
className = "",
}: SSHTerminalWrapperProps) => {
const terminalRef = useRef<HTMLDivElement>(null);
const terminalInstanceRef = useRef<{
terminal: TerminalWithCore;
fitAddon: any;
} | null>(null);
const handlersSetRef = useRef(false);
const fitTerminal = useCallback(() => {
if (terminalInstanceRef.current?.fitAddon) {
terminalInstanceRef.current.fitAddon.fit();
}
}, []);
const initializeTerminal = useCallback(async () => {
if (terminalInstanceRef.current || !terminalRef.current) return;
const { Terminal: XTerminal } = await import("@xterm/xterm");
const { FitAddon } = await import("@xterm/addon-fit");
const terminal = new XTerminal(TERMINAL_OPTIONS);
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
if (!terminalRef.current) return;
terminalRef.current.innerHTML = "";
terminal.open(terminalRef.current);
// Set terminal focus behavior
const terminalElement = terminalRef.current.querySelector(
".xterm",
) as HTMLElement;
if (terminalElement) {
terminalElement.setAttribute("tabindex", "0");
terminalElement.addEventListener("click", () => terminal.focus());
terminalElement.addEventListener("keydown", (e) => e.stopPropagation());
}
terminalInstanceRef.current = {
terminal: terminal as TerminalWithCore,
fitAddon,
};
// Initial fit with delay to ensure proper sizing
setTimeout(fitTerminal, 100);
return terminal as TerminalWithCore;
}, [fitTerminal]);
const setupSSHHandlers = useCallback(async () => {
if (!session || handlersSetRef.current) return;
const terminal = await initializeTerminal();
if (!terminal) return;
handlersSetRef.current = true;
// Setup terminal event handlers
terminal.onData((data: string) => session?.write?.(data));
terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => {
session?.resize?.(cols, rows);
onResize?.(cols, rows);
});
// Setup SSH event handlers
session.ondata = (data: Uint8Array) => {
if (!terminal._core?._isDisposed) {
terminal.write(new Uint8Array(data));
}
};
const originalOnClose = session.onclose;
session.onclose = () => {
if (!terminal._core?._isDisposed) {
terminal.writeln("\r\n*** Connection closed ***");
}
handlersSetRef.current = false;
originalOnClose?.();
onClose?.();
};
// Final setup with proper sizing
setTimeout(() => {
if (
terminalInstanceRef.current?.fitAddon &&
!terminal._core?._isDisposed
) {
fitTerminal();
session?.resize?.(terminal.cols, terminal.rows);
terminal.focus();
}
}, 200);
}, [session, initializeTerminal, onResize, onClose, fitTerminal]);
// Handle window resize
useEffect(() => {
const handleResize = () => fitTerminal();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [fitTerminal]);
// Setup SSH handlers when session changes
useEffect(() => {
setupSSHHandlers().then();
}, [setupSSHHandlers]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (terminalInstanceRef.current?.terminal) {
const terminal = terminalInstanceRef.current.terminal;
if (!terminal._core?._isDisposed) {
terminal.dispose();
}
terminalInstanceRef.current = null;
}
handlersSetRef.current = false;
};
}, []);
return (
<div
ref={terminalRef}
className={cn("w-full h-full flex flex-col m-0 p-0", className)}
/>
);
};

View File

@@ -0,0 +1,85 @@
import { useCallback, useRef, useState } from "react";
interface SSHConfig {
hostname: string;
port: number;
username: string;
}
interface SSHConnection {
write: (data: string) => void;
resize: (cols: number, rows: number) => void;
close: () => void;
ondata: ((data: Uint8Array) => void) | null;
onclose: (() => void) | null;
}
export enum SSHStatus {
DISCONNECTED = 0,
CONNECTED = 1,
CONNECTING = 2,
}
export const SSH_DOCS_LINK = "https://docs.netbird.io/";
export const useSSH = (client: any) => {
const [status, setStatus] = useState(SSHStatus.DISCONNECTED);
const [config, setConfig] = useState<SSHConfig | null>(null);
const session = useRef<SSHConnection | null>(null);
const [error, setError] = useState("");
const connect = useCallback(
async (config: SSHConfig): Promise<SSHStatus> => {
if (status === SSHStatus.CONNECTED || status === SSHStatus.CONNECTING)
return status;
setStatus(SSHStatus.CONNECTING);
setConfig(config);
try {
const ssh = await client.createSSHConnection(
config.hostname,
config.port,
config.username,
);
ssh.onclose = () => {
setStatus(SSHStatus.DISCONNECTED);
setConfig(null);
session.current = null;
};
session.current = ssh;
setStatus(SSHStatus.CONNECTED);
return SSHStatus.CONNECTED;
} catch (err) {
console.error("SSH connection failed:", err);
session.current = null;
setStatus(SSHStatus.DISCONNECTED);
setError("SSH connection failed. Check the console for details.");
setConfig(null);
return SSHStatus.DISCONNECTED;
}
},
[client, status],
);
const disconnect = useCallback(() => {
if (session.current) {
session.current.close();
session.current = null;
setStatus(SSHStatus.DISCONNECTED);
setConfig(null);
}
}, []);
return {
connect,
error,
status,
config,
session: session.current,
disconnect,
};
};

View File

@@ -0,0 +1,70 @@
import { useLocalStorage } from "@hooks/useLocalStorage";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
interface SSHQueryParams {
peerId: string | null;
username: string | null;
port: string | null;
}
export function useSSHQueryParams() {
const searchParams = useSearchParams();
const router = useRouter();
const [params, setParams] = useState<SSHQueryParams>({
peerId: null,
username: null,
port: null,
});
const [, setLocalQueryParams] = useLocalStorage("netbird-query-params", "");
useEffect(() => {
const peerId = searchParams.get("id");
const username = searchParams.get("user");
const port = searchParams.get("port");
// If all params are present in URL, use them
if (peerId && username && port) {
setParams({ peerId, username, port });
return;
}
// Otherwise, try to restore from localStorage
const storedParams = localStorage.getItem("netbird-query-params");
if (!storedParams) return;
// Handle JSON encoded strings from localStorage
let paramsString = storedParams;
if (storedParams.startsWith('"') && storedParams.endsWith('"')) {
try {
paramsString = JSON.parse(storedParams);
} catch (e) {
return;
}
}
const urlParams = new URLSearchParams(paramsString);
const storedPeerId = urlParams.get("id");
const storedUsername = urlParams.get("user");
const storedPort = urlParams.get("port");
if (storedPeerId && storedUsername && storedPort) {
const newSearchParams = new URLSearchParams();
newSearchParams.set("id", storedPeerId);
newSearchParams.set("user", storedUsername);
newSearchParams.set("port", storedPort);
router.replace(`/peer/ssh?${newSearchParams.toString()}`);
setParams({
peerId: storedPeerId,
username: storedUsername,
port: storedPort,
});
// Clear stored params after restoring
setLocalQueryParams("");
}
}, [searchParams, router]);
return params;
}

View File

@@ -0,0 +1,306 @@
import loadConfig from "@utils/config";
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";
import { useApiCall } from "@utils/api";
import { generateKeypair } from "@utils/wireguard";
import { getBrowserInfo } from "@utils/helpers";
import { trim } from "lodash";
const config = loadConfig();
const WASM_CONFIG = {
SCRIPT_PATH: "/wasm_exec.js",
WASM_PATH: "/netbird.wasm",
INIT_TIMEOUT: 10000,
RETRY_DELAY: 100,
} as const;
export enum NetBirdStatus {
DISCONNECTED = 0,
CONNECTED = 1,
CONNECTING = 2,
}
export enum WASMStatus {
UNINITIALIZED,
INITIALIZED,
INITIALIZING,
}
type NetBirdState = {
status: NetBirdStatus;
wasmStatus: WASMStatus;
error: string;
};
class NetBirdStore {
private state: NetBirdState = {
status: NetBirdStatus.DISCONNECTED,
wasmStatus: WASMStatus.UNINITIALIZED,
error: "",
};
private listeners = new Set<() => void>();
getState = (): NetBirdState => this.state;
setState = (newState: Partial<NetBirdState>): void => {
this.state = { ...this.state, ...newState };
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
}
const netBirdStore = new NetBirdStore();
export const useNetBirdClient = () => {
const netBirdClient = useRef<any>(null);
const state = useSyncExternalStore(
netBirdStore.subscribe,
netBirdStore.getState,
netBirdStore.getState,
);
const { status, wasmStatus, error } = state;
const peerRequest = useApiCall(`/peers`);
const rdpComponents = useRef<{
bridge: IronRDPWASMBridge | null;
inputHandler: typeof IronRDPInputHandler | null;
certificateHandler: typeof RDPCertificateHandler | null;
}>({ bridge: null, inputHandler: null, certificateHandler: null });
const loadWASMRuntime = useCallback((): Promise<void> => {
if (document.querySelector(`script[src="${WASM_CONFIG.SCRIPT_PATH}"]`)) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = WASM_CONFIG.SCRIPT_PATH;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load WASM runtime"));
document.head.appendChild(script);
});
}, []);
const loadGoClient = useCallback(async (): Promise<void> => {
if ((window as any).NetBirdClient) return;
const go = new (window as any).Go();
const wasmModule = await WebAssembly.instantiateStreaming(
fetch(WASM_CONFIG.WASM_PATH),
go.importObject,
);
go.run(wasmModule.instance);
const start = Date.now();
while (Date.now() - start < WASM_CONFIG.INIT_TIMEOUT) {
if ((window as any).NetBirdClient) return;
await new Promise((resolve) =>
setTimeout(resolve, WASM_CONFIG.RETRY_DELAY),
);
}
throw new Error("NetBird WASM failed to initialize in time");
}, []);
const initIronRDP = useCallback(() => {
if (rdpComponents.current.bridge) return;
installWebSocketProxy();
rdpComponents.current = {
bridge: new IronRDPWASMBridge(),
inputHandler: IronRDPInputHandler,
certificateHandler: RDPCertificateHandler,
};
}, []);
const initialize = useCallback(async (): Promise<WASMStatus> => {
const currentStatus = netBirdStore.getState().wasmStatus;
if (currentStatus === WASMStatus.INITIALIZED) return currentStatus;
if (currentStatus === WASMStatus.INITIALIZING) {
while (netBirdStore.getState().wasmStatus === WASMStatus.INITIALIZING) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
const finalStatus = netBirdStore.getState().wasmStatus;
if (finalStatus === WASMStatus.INITIALIZED) return finalStatus;
throw new Error("WASM initialization failed");
}
netBirdStore.setState({ wasmStatus: WASMStatus.INITIALIZING });
try {
await loadWASMRuntime();
await loadGoClient();
initIronRDP();
netBirdStore.setState({ wasmStatus: WASMStatus.INITIALIZED });
return WASMStatus.INITIALIZED;
} catch (error) {
netBirdStore.setState({
wasmStatus: WASMStatus.UNINITIALIZED,
error:
error instanceof Error ? error.message : "Failed to initialize WASM",
});
throw error;
}
}, [loadWASMRuntime, loadGoClient, initIronRDP]);
const initializeIronRDP = useCallback(async (): Promise<boolean> => {
if (!rdpComponents.current.bridge) return false;
try {
await rdpComponents.current.bridge.initialize();
return true;
} catch {
return false;
}
}, []);
const connect = useCallback(
async (privateKey: string): Promise<boolean> => {
await initialize();
if (typeof (window as any).NetBirdClient !== "function") {
netBirdStore.setState({
status: NetBirdStatus.DISCONNECTED,
error: "NetBirdClient is not available or not a function",
});
return false;
}
netBirdStore.setState({ status: NetBirdStatus.CONNECTING });
try {
netBirdClient.current = await (window as any).NetBirdClient({
privateKey,
logLevel: "warn",
managementURL: config.apiOrigin,
});
await netBirdClient.current.start();
netBirdStore.setState({ status: NetBirdStatus.CONNECTED });
return true;
} catch (error) {
netBirdStore.setState({
status: NetBirdStatus.DISCONNECTED,
error: error instanceof Error ? error.message : "Connection failed",
});
console.log(error);
return false;
}
},
[initialize],
);
const disconnect = useCallback(async (): Promise<void> => {
if (!netBirdClient.current?.stop) {
throw new Error("Go client not ready");
}
netBirdStore.setState({ status: NetBirdStatus.DISCONNECTED });
await netBirdClient.current.stop();
netBirdClient.current = null;
return Promise.resolve();
}, []);
const createSSHConnection = useCallback(
async (host: string, port: number, username: string): Promise<any> => {
if (!netBirdClient.current?.createSSHConnection) {
throw new Error("Go client not ready");
}
return netBirdClient.current.createSSHConnection(host, port, username);
},
[],
);
const makeRequest = useCallback(async (url: string): Promise<any> => {
if (!netBirdClient.current?.makeRequest) {
throw new Error("Go client not ready");
}
return netBirdClient.current.makeRequest(url);
}, []);
const proxyRequest = useCallback(async (request: any): Promise<any> => {
if (!netBirdClient.current?.proxyRequest) {
throw new Error("Go client not ready");
}
return netBirdClient.current.proxyRequest(request);
}, []);
const setupRDPProxy = useCallback(
async (hostname: string, port: string): Promise<string> => {
if (!netBirdClient.current?.setupRDPProxy) {
throw new Error("Go client not ready");
}
return netBirdClient.current.setupRDPProxy(hostname, port);
},
[],
);
const connectTemporary = useCallback(
async (peerId: string, rules?: string[]) => {
const currentStatus = netBirdStore.getState().status;
if (
currentStatus === NetBirdStatus.CONNECTING ||
currentStatus === NetBirdStatus.CONNECTED
) {
return currentStatus === NetBirdStatus.CONNECTED;
}
netBirdStore.setState({ status: NetBirdStatus.CONNECTING });
try {
const keyPairs = generateKeypair();
const browser = getBrowserInfo();
const name =
browser.name === ""
? "browser-client"
: trim(
`${browser.name.toLowerCase()}-${browser.version.toLowerCase()}-browser-client`,
);
await peerRequest.post(
{
name,
wg_pub_key: keyPairs.publicKey,
rules: rules ?? ["tcp/22", "tcp/3389", "tcp/44338"],
},
`/${peerId}/temporary-access`,
);
return await connect(keyPairs.privateKey);
} catch (error) {
netBirdStore.setState({ status: NetBirdStatus.DISCONNECTED });
throw error;
}
},
[connect, peerRequest],
);
useEffect(() => {
initialize().catch(console.error);
}, [initialize]);
return {
status,
wasmStatus,
error,
client: netBirdClient.current, // Expose the raw NetBird client
ironRDPBridge: rdpComponents.current.bridge,
ironRDPInputHandler: rdpComponents.current.inputHandler,
rdpCertificateHandler: rdpComponents.current.certificateHandler,
initialize,
initializeIronRDP,
connect,
connectTemporary,
disconnect,
createSSHConnection,
makeRequest,
proxyRequest,
setupRDPProxy,
};
};

View File

@@ -212,3 +212,34 @@ export const formatBytes = (bytes: number, decimals: number = 2): string => {
return "0 B";
}
};
/**
* Get browser name and version from user agent
* @returns Object with name and version
*/
export const getBrowserInfo = () => {
let name = "";
let version = "";
try {
const ua = navigator.userAgent;
if (/firefox\/\d+/i.test(ua)) {
name = "Firefox";
version = ua.match(/firefox\/(\d+)/i)?.[1] || version;
} else if (/chrome\/\d+/i.test(ua) && !/edg\//i.test(ua)) {
name = "Chrome";
version = ua.match(/chrome\/(\d+)/i)?.[1] || version;
} else if (/safari\/\d+/i.test(ua) && !/chrome\/\d+/i.test(ua)) {
name = "Safari";
version = ua.match(/version\/(\d+)/i)?.[1] || version;
} else if (/edg\/\d+/i.test(ua)) {
name = "Edge";
version = ua.match(/edg\/(\d+)/i)?.[1] || version;
}
return { name, version };
} catch (e) {
return { name, version };
}
};

View File

@@ -54,3 +54,13 @@ export const isRoutingPeerSupported = (version: string, os: string) => {
const versionNumber = parseVersionString(version);
return versionNumber >= 366;
};
/**
* Check if native SSH is supported
* @param version
*/
export const isNativeSSHSupported = (version: string) => {
if (version == "development") return true;
const versionNumber = parseVersionString(version);
return versionNumber >= 999;
};

200
src/utils/wireguard.ts Normal file
View File

@@ -0,0 +1,200 @@
/*! SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2015-2020 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
*/
export interface WireguardKeypair {
publicKey: string;
privateKey: string;
}
function gf(init?: number[]): Float64Array {
const r = new Float64Array(16);
if (init) {
for (let i = 0; i < init.length; ++i) r[i] = init[i];
}
return r;
}
function pack(o: Uint8Array, n: Float64Array): void {
let b: number;
const m = gf();
const t = gf();
for (let i = 0; i < 16; ++i) t[i] = n[i];
carry(t);
carry(t);
carry(t);
for (let j = 0; j < 2; ++j) {
m[0] = t[0] - 0xffed;
for (let i = 1; i < 15; ++i) {
m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1);
m[i - 1] &= 0xffff;
}
m[15] = t[15] - 0x7fff - ((m[14] >> 16) & 1);
b = (m[15] >> 16) & 1;
m[14] &= 0xffff;
cswap(t, m, 1 - b);
}
for (let i = 0; i < 16; ++i) {
o[2 * i] = t[i] & 0xff;
o[2 * i + 1] = t[i] >> 8;
}
}
function carry(o: Float64Array): void {
for (let i = 0; i < 16; ++i) {
o[(i + 1) % 16] += (i < 15 ? 1 : 38) * Math.floor(o[i] / 65536);
o[i] &= 0xffff;
}
}
function cswap(p: Float64Array, q: Float64Array, b: number): void {
const c = ~(b - 1);
for (let i = 0; i < 16; ++i) {
const t = c & (p[i] ^ q[i]);
p[i] ^= t;
q[i] ^= t;
}
}
function add(o: Float64Array, a: Float64Array, b: Float64Array): void {
for (let i = 0; i < 16; ++i) o[i] = (a[i] + b[i]) | 0;
}
function subtract(o: Float64Array, a: Float64Array, b: Float64Array): void {
for (let i = 0; i < 16; ++i) o[i] = (a[i] - b[i]) | 0;
}
function multmod(o: Float64Array, a: Float64Array, b: Float64Array): void {
const t = new Float64Array(31);
for (let i = 0; i < 16; ++i) {
for (let j = 0; j < 16; ++j) t[i + j] += a[i] * b[j];
}
for (let i = 0; i < 15; ++i) t[i] += 38 * t[i + 16];
for (let i = 0; i < 16; ++i) o[i] = t[i];
carry(o);
carry(o);
}
function invert(o: Float64Array, i: Float64Array): void {
const c = gf();
for (let a = 0; a < 16; ++a) c[a] = i[a];
for (let a = 253; a >= 0; --a) {
multmod(c, c, c);
if (a !== 2 && a !== 4) multmod(c, c, i);
}
for (let a = 0; a < 16; ++a) o[a] = c[a];
}
function clamp(z: Uint8Array): void {
z[31] = (z[31] & 127) | 64;
z[0] &= 248;
}
function generatePresharedKey(): Uint8Array {
const privateKey = new Uint8Array(32);
if (
typeof window !== "undefined" &&
window.crypto &&
window.crypto.getRandomValues
) {
window.crypto.getRandomValues(privateKey);
} else if (typeof require !== "undefined") {
// Node.js fallback
const crypto = require("crypto");
const buf = crypto.randomBytes(32);
for (let i = 0; i < 32; ++i) privateKey[i] = buf[i];
} else {
throw new Error("No secure random number generator available");
}
return privateKey;
}
function generatePrivateKey(): Uint8Array {
const privateKey = generatePresharedKey();
clamp(privateKey);
return privateKey;
}
function generatePublicKey(privateKey: Uint8Array): Uint8Array {
let r: number;
const z = new Uint8Array(32);
const a = gf([1]),
b = gf([9]),
c = gf(),
d = gf([1]),
e = gf(),
f = gf(),
_121665 = gf([0xdb41, 1]),
_9 = gf([9]);
for (let i = 0; i < 32; ++i) z[i] = privateKey[i];
clamp(z);
for (let i = 254; i >= 0; --i) {
r = (z[i >>> 3] >>> (i & 7)) & 1;
cswap(a, b, r);
cswap(c, d, r);
add(e, a, c);
subtract(a, a, c);
add(c, b, d);
subtract(b, b, d);
multmod(d, e, e);
multmod(f, a, a);
multmod(a, c, a);
multmod(c, b, e);
add(e, a, c);
subtract(a, a, c);
multmod(b, a, a);
subtract(c, d, f);
multmod(a, c, _121665);
add(a, a, d);
multmod(c, c, a);
multmod(a, d, f);
multmod(d, b, _9);
multmod(b, e, e);
cswap(a, b, r);
cswap(c, d, r);
}
invert(c, c);
multmod(a, a, c);
pack(z, a);
return z;
}
function encodeBase64(dest: Uint8Array, src: Uint8Array): void {
const input = Uint8Array.from([
(src[0] >> 2) & 63,
((src[0] << 4) | (src[1] >> 4)) & 63,
((src[1] << 2) | (src[2] >> 6)) & 63,
src[2] & 63,
]);
for (let i = 0; i < 4; ++i)
dest[i] =
input[i] +
65 +
(((25 - input[i]) >> 8) & 6) -
(((51 - input[i]) >> 8) & 75) -
(((61 - input[i]) >> 8) & 15) +
(((62 - input[i]) >> 8) & 3);
}
function keyToBase64(key: Uint8Array): string {
let i: number;
const base64 = new Uint8Array(44);
for (i = 0; i < 32 / 3; ++i)
encodeBase64(base64.subarray(i * 4), key.subarray(i * 3));
encodeBase64(
base64.subarray(i * 4),
Uint8Array.from([key[i * 3], key[i * 3 + 1], 0]),
);
base64[43] = 61;
return String.fromCharCode(...base64);
}
export function generateKeypair(): WireguardKeypair {
const privateKey = generatePrivateKey();
const publicKey = generatePublicKey(privateKey);
return {
publicKey: keyToBase64(publicKey),
privateKey: keyToBase64(privateKey),
};
}