diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml
index 2eff4e0..20b9dfd 100644
--- a/.github/workflows/build_and_push.yml
+++ b/.github/workflows/build_and_push.yml
@@ -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
-
diff --git a/.gitignore b/.gitignore
index c2c0d17..5cba48e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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*
\ No newline at end of file
diff --git a/docker/default.conf b/docker/default.conf
index 1aad77a..4be2fca 100644
--- a/docker/default.conf
+++ b/docker/default.conf
@@ -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;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index c3cb5e7..9402467 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -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
diff --git a/package-lock.json b/package-lock.json
index b11a910..0f58b98 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 4a48dc1..ef1015d 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/public/wasm_exec.js b/public/wasm_exec.js
new file mode 100644
index 0000000..d71af9e
--- /dev/null
+++ b/public/wasm_exec.js
@@ -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;
+ };
+ }
+ }
+})();
diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx
index d2bfa5e..59f36b9 100644
--- a/src/app/(dashboard)/peer/page.tsx
+++ b/src/app/(dashboard)/peer/page.tsx
@@ -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 = () => {
/>
+ {/* Remote Access Buttons */}
+
+
+
Connect directly to this peer via SSH or RDP.
+
+
+
+
+
+
{permission.groups.read && (
diff --git a/src/app/(remote-access)/layout.tsx b/src/app/(remote-access)/layout.tsx
new file mode 100644
index 0000000..c0798f3
--- /dev/null
+++ b/src/app/(remote-access)/layout.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+import UsersProvider from "@/contexts/UsersProvider";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
{children}
+ );
+}
diff --git a/src/app/(remote-access)/peer/rdp/page.tsx b/src/app/(remote-access)/peer/rdp/page.tsx
new file mode 100644
index 0000000..f760fb5
--- /dev/null
+++ b/src/app/(remote-access)/peer/rdp/page.tsx
@@ -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
(`/peers/${peerId}`, true, false, !!peerId);
+
+ return (
+
+ {peerId && peer && !isLoading ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+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(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: ,
+ 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 */}
+
+
+ {/* Certificate Modal */}
+ {
+ rdp.rejectCertificatePrompt();
+ await reset();
+ }}
+ />
+
+ {rdp.isResizing && (
+
+
+
+ )}
+
+ {/* RDP Canvas */}
+
+ >
+ );
+}
diff --git a/src/app/(remote-access)/peer/ssh/page.tsx b/src/app/(remote-access)/peer/ssh/page.tsx
new file mode 100644
index 0000000..c1f73dc
--- /dev/null
+++ b/src/app/(remote-access)/peer/ssh/page.tsx
@@ -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(`/peers/${peerId}`, true, false, !!peerId);
+
+ if (error) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {peerId && peer && !isLoading && username && port ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+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 ;
+ }
+
+ if (sshError) {
+ return ;
+ }
+
+ if (isSSHDisconnected && sshConnectedOnce.current) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+ {session && }
+ {!isSSHConnected && (
+
+ )}
+ >
+ );
+}
+
+type MessageProps = {
+ message?: string;
+ error?: ErrorResponse;
+};
+
+const LoadingMessage = ({ message }: MessageProps) => {
+ return (
+
+ );
+};
+
+const ErrorMessage = ({ error }: MessageProps) => {
+ return (
+
+
+
+ {error?.message}
+
+
+ );
+};
+
+type DisconnectedMessageProps = {
+ username: string;
+ peerIp: string;
+ onReconnect: () => void;
+};
+
+const DisconnectedMessage = ({
+ username,
+ peerIp,
+ onReconnect,
+}: DisconnectedMessageProps) => {
+ return (
+
+
+
+ Disconnected from {username}@{peerIp}
+
+
+
+ );
+};
diff --git a/src/app/globals.css b/src/app/globals.css
index f0410be..cbefb21 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/auth/OIDCProvider.tsx b/src/auth/OIDCProvider.tsx
index 29e0ec5..2473721 100644
--- a/src/auth/OIDCProvider.tsx
+++ b/src/auth/OIDCProvider.tsx
@@ -58,6 +58,8 @@ export default function OIDCProvider({ children }: Props) {
"utm_content",
"utm_campaign",
"hs_id",
+ "user",
+ "port",
];
try {
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 9e4b912..0697128 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -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: {
diff --git a/src/components/Callout.tsx b/src/components/Callout.tsx
index 8b453f4..56481df 100644
--- a/src/components/Callout.tsx
+++ b/src/components/Callout.tsx
@@ -26,7 +26,7 @@ export const calloutVariants = cva(
export const Callout = ({
children,
- icon = ,
+ icon = ,
className,
variant = "default",
}: Props) => {
diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx
index 42068e5..607faff 100644
--- a/src/components/PeerGroupSelector.tsx
+++ b/src/components/PeerGroupSelector.tsx
@@ -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) {
+ const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
+ NetworkResource[]
+ >("/networks/resources");
+
+ const { data: peers, isLoading: isPeersLoading } =
+ useFetchApi("/peers");
+
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
useGroups();
+
const searchRef = React.useRef(null);
+
const [inputRef, { width }] = useElementSize<
HTMLButtonElement | HTMLSpanElement
>();
- const [search, setSearch] = useState("");
- const { data: resources, isLoading } = useFetchApi(
- "/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 ;
- }, []);
-
- const peerIcon = useMemo(() => {
- return ;
- }, []);
-
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 (
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}
>
- {
- const formatValue = trim(value.toLowerCase());
- const formatSearch = trim(search.toLowerCase());
- if (formatValue.includes(formatSearch)) return 1;
- return 0;
- }}
- >
+
- {showResources && }
+
{searchedGroupNotFound && (
@@ -433,8 +453,8 @@ export function PeerGroupSelector({
value={search}
onClick={(e) => e.preventDefault()}
>
-
- {folderIcon}
+
+
{search}
)}
- {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()}
>
-
+
@@ -509,7 +533,10 @@ export function PeerGroupSelector({
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
}
>
- {peerIcon}
+
{peerCount} Peer(s)
) : (
@@ -535,12 +562,23 @@ export function PeerGroupSelector({
)}
+ {showPeers && (
+
+
+
+ )}
@@ -551,9 +589,14 @@ export function PeerGroupSelector({
const TabTriggers = ({
searchRef,
+ showResources = false,
+ showPeers = false,
}: {
searchRef: React.MutableRefObject
;
+ showResources?: boolean;
+ showPeers?: boolean;
}) => {
+ if (!showResources && !showPeers) return null;
return (
Groups
- searchRef.current?.focus()}
- >
-
- Resource
-
+
+ {showResources && (
+ searchRef.current?.focus()}
+ >
+
+ Resources
+
+ )}
+
+ {showPeers && (
+ searchRef.current?.focus()}
+ >
+
+ Peers
+
+ )}
);
};
@@ -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 = ({
);
};
+
+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 (
+
+
+
+
+
+
+ );
+ }
+
+ if (search != "" && filteredItems.length == 0) {
+ return (
+
+ There are no peers matching your search. Please try a different search
+ term.
+
+ );
+ }
+
+ if (search == "" && filteredItems.length == 0) {
+ return (
+
+ There are no peers available yet.
+ Go to Peers to add some peers.
+
+ );
+ }
+
+ return (
+
+ {
+ if (!res?.id) return;
+
+ return (
+
+
+
{
+ e.preventDefault();
+ }}
+ >
+
+
+
+
+
+
+
+ );
+ }}
+ />
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/PeerSelector.tsx b/src/components/PeerSelector.tsx
index e571b7b..520a9df 100644
--- a/src/components/PeerSelector.tsx
+++ b/src/components/PeerSelector.tsx
@@ -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(() => );
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",
)}
>
-
-
-
-
+
= {
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 = {
estimatedHeadingHeight?: number;
heightAdjustment?: number;
groupKey?: (item: T) => string | undefined;
+ itemKey?: (item: T) => string;
};
export function VirtualScrollAreaList({
@@ -30,6 +32,7 @@ export function VirtualScrollAreaList({
renderBeforeItem,
renderHeading,
itemClassName,
+ itemClassNameWithItem,
itemWrapperClassName,
scrollAreaClassName,
maxHeight,
@@ -37,6 +40,7 @@ export function VirtualScrollAreaList({
estimatedHeadingHeight = 16,
heightAdjustment = 8,
groupKey,
+ itemKey,
}: Readonly>) {
const virtuosoRef = useRef(null);
const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">(
@@ -159,10 +163,14 @@ export function VirtualScrollAreaList({
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}
>
diff --git a/src/components/table/DataTableRefreshButton.tsx b/src/components/table/DataTableRefreshButton.tsx
index 18d5256..0eb7010 100644
--- a/src/components/table/DataTableRefreshButton.tsx
+++ b/src/components/table/DataTableRefreshButton.tsx
@@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) {
}}
>
);
+ if (event.activity_code == "peer.user.add")
+ return (
+
+ Peer
{m.name} was added
+ with the NetBird IP
{m.ip}
+
+ );
+
/**
* Group
*/
diff --git a/src/modules/common-table-rows/ActiveInactiveRow.tsx b/src/modules/common-table-rows/ActiveInactiveRow.tsx
index b391b72..8939078 100644
--- a/src/modules/common-table-rows/ActiveInactiveRow.tsx
+++ b/src/modules/common-table-rows/ActiveInactiveRow.tsx
@@ -37,12 +37,13 @@ export default function ActiveInactiveRow({
{additionalInfo}
diff --git a/src/modules/peer/EphemeralPeerIndicator.tsx b/src/modules/peer/EphemeralPeerIndicator.tsx
new file mode 100644
index 0000000..679e0f3
--- /dev/null
+++ b/src/modules/peer/EphemeralPeerIndicator.tsx
@@ -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 (
+ {tooltipContent}
}>
+
+
+ );
+};
diff --git a/src/modules/peer/ExpirationDisabledIndicator.tsx b/src/modules/peer/ExpirationDisabledIndicator.tsx
new file mode 100644
index 0000000..2faa441
--- /dev/null
+++ b/src/modules/peer/ExpirationDisabledIndicator.tsx
@@ -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 (
+
{tooltipContent} }
+ >
+
+
+ );
+};
diff --git a/src/modules/peer/LoginRequiredIndicator.tsx b/src/modules/peer/LoginRequiredIndicator.tsx
new file mode 100644
index 0000000..a61fd3a
--- /dev/null
+++ b/src/modules/peer/LoginRequiredIndicator.tsx
@@ -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 (
+
+ {" "}
+ This peer is offline and needs to be
+ re-authenticated because its login has expired.
+
+ }
+ >
+
+
+ );
+};
diff --git a/src/modules/peers/PeerActionCell.tsx b/src/modules/peers/PeerActionCell.tsx
index 58559ed..02bdd46 100644
--- a/src/modules/peers/PeerActionCell.tsx
+++ b/src/modules/peers/PeerActionCell.tsx
@@ -64,7 +64,7 @@ export default function PeerActionCell() {
};
return (
-
+
{
e.stopPropagation();
@@ -32,13 +32,13 @@ export default function PeerAddressCell({ peer }: Props) {
>
{isEmpty(peer.country_code) ? (
) : (
-
+
)}
diff --git a/src/modules/peers/PeerConnectButton.tsx b/src/modules/peers/PeerConnectButton.tsx
new file mode 100644
index 0000000..5717ea3
--- /dev/null
+++ b/src/modules/peers/PeerConnectButton.tsx
@@ -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 ? (
+ <>
+
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ >
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+
+ Connecting via SSH or RDP is only available when the peer is online.
+
+ }
+ >
+
+
+ );
+};
+
+const ConnectButton = ({ disabled }: { disabled?: boolean }) => {
+ return (
+
{
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ >
+ Connect
+
+
+ );
+};
diff --git a/src/modules/peers/PeerNameCell.tsx b/src/modules/peers/PeerNameCell.tsx
index b7aa3ed..11441b4 100644
--- a/src/modules/peers/PeerNameCell.tsx
+++ b/src/modules/peers/PeerNameCell.tsx
@@ -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) {
+ isOwnerOrAdmin && (
+ <>
+
+
+
+
+ >
+ )
}
>
diff --git a/src/modules/peers/PeerOperatingSystemIcon.tsx b/src/modules/peers/PeerOperatingSystemIcon.tsx
new file mode 100644
index 0000000..36db767
--- /dev/null
+++ b/src/modules/peers/PeerOperatingSystemIcon.tsx
@@ -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 (
+
+
+
+ );
+};
diff --git a/src/modules/peers/PeerStatusCell.tsx b/src/modules/peers/PeerStatusCell.tsx
index 7c24b2f..f93a716 100644
--- a/src/modules/peers/PeerStatusCell.tsx
+++ b/src/modules/peers/PeerStatusCell.tsx
@@ -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 ? (
-
-
- The peer needs to be approved by an administrator before it can
- connect to other peers.
-
- }
- interactive={false}
- >
-
-
- Approval required
-
-
-
{
- e.preventDefault();
- e.stopPropagation();
- if (!canApprove) return;
- approvePeer();
- }}
- >
- Approve
-
-
- ) : (
-
- {!peer.login_expiration_enabled && (
-
-
- Expiration disabled
-
- )}
-
-
-
- );
-}
+ return (
+ needsApproval && (
+
+
+ The peer needs to be approved by an administrator before it can
+ connect to other peers.
+
+ }
+ interactive={false}
+ >
+
+
+ Approval required
+
+
+ { canApprove && (
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ if (!canApprove) return;
+ approvePeer();
+ }}
+ >
+ Approve
+
+ )}
+
+ )
+ );
+}
\ No newline at end of file
diff --git a/src/modules/peers/PeerVersionCell.tsx b/src/modules/peers/PeerVersionCell.tsx
index 6e71591..0ac6d24 100644
--- a/src/modules/peers/PeerVersionCell.tsx
+++ b/src/modules/peers/PeerVersionCell.tsx
@@ -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
;
}, []);
- return updateAvailable ? (
-
-
-
-
-
- {version == "development" ? "dev" : version}
-
-
- {updateIcon}
+ return (
+
+ {updateAvailable ? (
+
+
+
+
+
+ {version == "development" ? "dev" : version}
+
+
+ {updateIcon}
+
+
+
+
+
+
+
+
{version}
+
+
{latestVersion}
+
+ Update available
+
+
+ A new version of Netbird is available. Please update your client
+ to get the latest features and bug fixes.
+
+ e.stopPropagation()}
+ href={latestUrl as string}
+ target={"_blank"}
+ className={"mt-2 mb-2 text-xs"}
+ >
+ Download & Changelog
+
+
+
+
+ ) : (
+
+
+ {version == "development" ? "dev" : version}
+
+ )}
+
+ {os && os !== "" && os !== " " && (
+
+ Serial:
+ {serial}
-
-
-
-
+ }
+ >
-
-
{version}
-
-
{latestVersion}
-
- Update available
+
-
- A new version of Netbird is available. Please update your client to
- get the latest features and bug fixes.
+ {os}
- e.stopPropagation()}
- href={latestUrl as string}
- target={"_blank"}
- className={"mt-2 mb-2 text-xs"}
- >
- Download & Changelog
-
-
-
-
- ) : (
-
-
- {version == "development" ? "dev" : version}
+
+ )}
);
}
diff --git a/src/modules/peers/PeersTable.tsx b/src/modules/peers/PeersTable.tsx
index fb1435e..78237db 100644
--- a/src/modules/peers/PeersTable.tsx
+++ b/src/modules/peers/PeersTable.tsx
@@ -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
[] = [
{
@@ -72,6 +75,16 @@ const PeersTableColumns: ColumnDef[] = [
sortingFn: "text",
cell: ({ row }) => ,
},
+ {
+ id: "connect",
+ accessorKey: "id",
+ header: "",
+ cell: ({ row }) => (
+
+
+
+ ),
+ },
{
id: "approval_required",
accessorKey: "approval_required",
@@ -157,7 +170,11 @@ const PeersTableColumns: ColumnDef[] = [
return Version;
},
cell: ({ row }) => (
-
+
),
},
{
@@ -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 (
<>
)}
+ {browserPeers?.length > 0 && (
+
+ Show temporary peers created by the NetBird browser client.
+ These peers are ephemeral and will be deleted automatically
+ after a short period of time.
+
+ }
+ >
+ {
+ setShowBrowserPeers(!showBrowserPeers);
+ }}
+ >
+
+
+
+ )}
+
{
diff --git a/src/modules/remote-access/rdp/RDPButton.tsx b/src/modules/remote-access/rdp/RDPButton.tsx
new file mode 100644
index 0000000..d16cba1
--- /dev/null
+++ b/src/modules/remote-access/rdp/RDPButton.tsx
@@ -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 && (
+ <>
+
+
+ {isDropdown ? (
+
+
+
+ RDP
+
+
+ ) : (
+
+
+ RDP
+ {disabled && }
+
+ )}
+
+
+ >
+ )
+ );
+};
diff --git a/src/modules/remote-access/rdp/RDPCertificateModal.tsx b/src/modules/remote-access/rdp/RDPCertificateModal.tsx
new file mode 100644
index 0000000..c9d057d
--- /dev/null
+++ b/src/modules/remote-access/rdp/RDPCertificateModal.tsx
@@ -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 (
+
+
+ }
+ title={"RDP Certificate"}
+ description={hostname}
+ color={"netbird"}
+ />
+
+
+
+ {isChange && (
+
+ Warning! Certificate has changed. Only proceed if you trust this
+ connection.
+
+ )}
+
+
+
+
+ Certificated could not be verified by a trusted authority. Review
+ the certificate information before proceeding with the connection.
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+ onAccept(rememberCertificate)}
+ >
+ Accept & Continue
+
+
+
+
+
+ );
+};
+
+const CertificateDetailsList = ({
+ certificate,
+}: {
+ certificate: CertificateInfo;
+}) => {
+ if (!certificate) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const CertificateDetailsListItem = ({
+ label,
+ value,
+}: {
+ label: string;
+ value: string;
+}) => {
+ return (
+
+ {label}:
+
+ {value}
+
+
+ );
+};
diff --git a/src/modules/remote-access/rdp/RDPCredentialsModal.tsx b/src/modules/remote-access/rdp/RDPCredentialsModal.tsx
new file mode 100644
index 0000000..1120798
--- /dev/null
+++ b/src/modules/remote-access/rdp/RDPCredentialsModal.tsx
@@ -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 (
+
+
+ }
+ title={peer.name}
+ description={`Connect to ${peer.ip} via RDP`}
+ color={"netbird"}
+ />
+
+
+
+
+
+
+
+ Learn more about
+
+ RDP
+
+
+
+
+
+
+ {loading && }
+ Connect
+
+
+
+
+
+ );
+};
diff --git a/src/modules/remote-access/rdp/RDPTooltip.tsx b/src/modules/remote-access/rdp/RDPTooltip.tsx
new file mode 100644
index 0000000..e3bafd3
--- /dev/null
+++ b/src/modules/remote-access/rdp/RDPTooltip.tsx
@@ -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 (
+
+ {hasPermission ? (
+ This peer is offline and cannot be accessed via RDP.
+ ) : (
+
+ You do not have permission to launch an RDP session. Please
+ contact your administrator.
+
+ )}
+
+ }
+ disabled={disabled}
+ >
+ {children}
+
+ );
+};
diff --git a/src/modules/remote-access/rdp/ironrdp-input-handler.ts b/src/modules/remote-access/rdp/ironrdp-input-handler.ts
new file mode 100644
index 0000000..8cb05ed
--- /dev/null
+++ b/src/modules/remote-access/rdp/ironrdp-input-handler.ts
@@ -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 = {
+ 0: false,
+ 1: false,
+ 2: false,
+ };
+ private keyStates = new Map();
+ 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 = {
+ // 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 = {
+ 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;
+}
diff --git a/src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts b/src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts
new file mode 100644
index 0000000..c4c5a5e
--- /dev/null
+++ b/src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts
@@ -0,0 +1,450 @@
+export interface IronRDPModule {
+ SessionBuilder: new () => SessionBuilder;
+ DesktopSize: new (width: number, height: number) => DesktopSize;
+ ClipboardData?: new () => ClipboardData;
+ default?: () => Promise;
+ init?: () => Promise;
+}
+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;
+}
+export interface RDPSession {
+ run(): Promise;
+ shutdown(): void;
+ sendInput(input: unknown): void;
+ onClipboardPaste?(content: ClipboardData): Promise;
+ 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;
+ onIronRDPReady?: () => void;
+ createRDCleanPathProxy?: (
+ hostname: string,
+ port: number,
+ ) => Promise;
+ }
+}
+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();
+ private lastClipboardContent = "";
+ private clipboardEventListeners: (() => void)[] = [];
+
+ // Expose clipboard sync method for input handler
+ async initialize(): Promise {
+ 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;
+ },
+ ): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ try {
+ await window.IronRDPBridge.initialize();
+ return true;
+ } catch (error) {
+ console.error("Failed to initialize IronRDP:", error);
+ return false;
+ }
+ };
+}
diff --git a/src/modules/remote-access/rdp/rdp-certificate-handler.ts b/src/modules/remote-access/rdp/rdp-certificate-handler.ts
new file mode 100644
index 0000000..1ced1e2
--- /dev/null
+++ b/src/modules/remote-access/rdp/rdp-certificate-handler.ts
@@ -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;
+ handleRDCleanPathResponse(response: RDCleanPathResponse): Promise;
+}
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 = `
+ ⚠️ Certificate has changed!
+ Previous fingerprint: ${oldCert.fingerprint.substring(0, 32)}...
+ `;
+ 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 {
+ 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 = `
+
+
+
RDP Certificate Verification
+
+
The server ${hostname} is presenting a certificate:
+
+
+ | Subject: | ${certInfo.subject || 'Unknown'} |
+ | Issuer: | ${certInfo.issuer || 'Unknown'} |
+ ${certInfo.serialNumber ? `| Serial: | ${certInfo.serialNumber} |
` : ''}
+ | SHA-256: |
+ ${certInfo.fingerprint} |
+
+
+
+
Do you trust this certificate?
+
+
+
+ Reject
+ Accept
+
+
+ `;
+ // 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;
+}
\ No newline at end of file
diff --git a/src/modules/remote-access/rdp/useRDPCertificateHandler.ts b/src/modules/remote-access/rdp/useRDPCertificateHandler.ts
new file mode 100644
index 0000000..db6a80f
--- /dev/null
+++ b/src/modules/remote-access/rdp/useRDPCertificateHandler.ts
@@ -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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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,
+ };
+};
diff --git a/src/modules/remote-access/rdp/useRDPQueryParams.ts b/src/modules/remote-access/rdp/useRDPQueryParams.ts
new file mode 100644
index 0000000..8ac5cb3
--- /dev/null
+++ b/src/modules/remote-access/rdp/useRDPQueryParams.ts
@@ -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({
+ 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;
+}
diff --git a/src/modules/remote-access/rdp/useRemoteDesktop.ts b/src/modules/remote-access/rdp/useRemoteDesktop.ts
new file mode 100644
index 0000000..636bcac
--- /dev/null
+++ b/src/modules/remote-access/rdp/useRemoteDesktop.ts
@@ -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(null);
+ const [error, setError] = useState("");
+
+ const [pendingCertificate, setPendingCertificate] =
+ useState(null);
+
+ const session = useRef(null);
+ const canvasRef = useRef(null);
+ const [isResizing, setIsResizing] = useState(false);
+ const resizeTimeoutRef = useRef(null);
+ const lastConnectedConfigRef = useRef(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 => {
+ 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,
+ };
+};
diff --git a/src/modules/remote-access/rdp/websocket-proxy.ts b/src/modules/remote-access/rdp/websocket-proxy.ts
new file mode 100644
index 0000000..da950e5
--- /dev/null
+++ b/src/modules/remote-access/rdp/websocket-proxy.ts
@@ -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;
+ getRDCleanPathCertificate?: (proxyID: string) => Promise;
+ 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;
+
+ 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 {
+ 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 {
+ 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;
+}
\ No newline at end of file
diff --git a/src/modules/remote-access/ssh/SSHButton.tsx b/src/modules/remote-access/ssh/SSHButton.tsx
new file mode 100644
index 0000000..cef1514
--- /dev/null
+++ b/src/modules/remote-access/ssh/SSHButton.tsx
@@ -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 && (
+
+ )}
+
+
+ {isDropdown ? (
+ setModal(true)}
+ disabled={disabled}
+ className={"w-full"}
+ >
+
+
+ SSH
+
+
+ ) : (
+ setModal(true)}
+ disabled={disabled}
+ >
+
+ SSH
+ {disabled && }
+
+ )}
+
+
+ >
+ )
+ );
+};
diff --git a/src/modules/remote-access/ssh/SSHCredentialsModal.tsx b/src/modules/remote-access/ssh/SSHCredentialsModal.tsx
new file mode 100644
index 0000000..7d5d575
--- /dev/null
+++ b/src/modules/remote-access/ssh/SSHCredentialsModal.tsx
@@ -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 (
+
+
+ }
+ title={peer.name}
+ description={`Connect to ${peer.ip} via SSH`}
+ color={"netbird"}
+ />
+
+
+
+
+
+
+
+ Learn more about
+
+ SSH
+
+
+
+
+
+
+ Cancel
+
+
+
+ Connect
+
+
+
+
+
+ );
+};
diff --git a/src/modules/remote-access/ssh/SSHTooltip.tsx b/src/modules/remote-access/ssh/SSHTooltip.tsx
new file mode 100644
index 0000000..779920d
--- /dev/null
+++ b/src/modules/remote-access/ssh/SSHTooltip.tsx
@@ -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 (
+
+ {hasPermission ? (
+ <>
+
+ This peer is either offline or SSH access is not enabled.
+
+
+ Please enable SSH access for this peer in the dashboard and make
+ sure SSH is allowed in the NetBird Client under{" "}
+ Settings → Allow SSH.
+
+
+ Learn more about{" "}
+
+ SSH
+
+
+ >
+ ) : (
+
+ You do not have permission to launch the SSH console. Please
+ contact your administrator.
+
+ )}
+
+ }
+ disabled={disabled}
+ >
+ {children}
+
+ );
+};
diff --git a/src/modules/remote-access/ssh/Terminal.tsx b/src/modules/remote-access/ssh/Terminal.tsx
new file mode 100644
index 0000000..fbfb15d
--- /dev/null
+++ b/src/modules/remote-access/ssh/Terminal.tsx
@@ -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
(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 (
+
+ );
+};
diff --git a/src/modules/remote-access/ssh/useSSH.ts b/src/modules/remote-access/ssh/useSSH.ts
new file mode 100644
index 0000000..7f40b2c
--- /dev/null
+++ b/src/modules/remote-access/ssh/useSSH.ts
@@ -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(null);
+ const session = useRef(null);
+ const [error, setError] = useState("");
+
+ const connect = useCallback(
+ async (config: SSHConfig): Promise => {
+ 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,
+ };
+};
diff --git a/src/modules/remote-access/ssh/useSSHQueryParams.ts b/src/modules/remote-access/ssh/useSSHQueryParams.ts
new file mode 100644
index 0000000..938f95e
--- /dev/null
+++ b/src/modules/remote-access/ssh/useSSHQueryParams.ts
@@ -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({
+ 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;
+}
diff --git a/src/modules/remote-access/useNetBirdClient.ts b/src/modules/remote-access/useNetBirdClient.ts
new file mode 100644
index 0000000..b535556
--- /dev/null
+++ b/src/modules/remote-access/useNetBirdClient.ts
@@ -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): 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(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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ if (!rdpComponents.current.bridge) return false;
+ try {
+ await rdpComponents.current.bridge.initialize();
+ return true;
+ } catch {
+ return false;
+ }
+ }, []);
+
+ const connect = useCallback(
+ async (privateKey: string): Promise => {
+ 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 => {
+ 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 => {
+ 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 => {
+ if (!netBirdClient.current?.makeRequest) {
+ throw new Error("Go client not ready");
+ }
+ return netBirdClient.current.makeRequest(url);
+ }, []);
+
+ const proxyRequest = useCallback(async (request: any): Promise => {
+ 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 => {
+ 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,
+ };
+};
diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts
index 79fa894..bf7fea2 100644
--- a/src/utils/helpers.ts
+++ b/src/utils/helpers.ts
@@ -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 };
+ }
+};
diff --git a/src/utils/version.ts b/src/utils/version.ts
index 1f089cc..5a35789 100644
--- a/src/utils/version.ts
+++ b/src/utils/version.ts
@@ -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;
+};
diff --git a/src/utils/wireguard.ts b/src/utils/wireguard.ts
new file mode 100644
index 0000000..cfe5fa0
--- /dev/null
+++ b/src/utils/wireguard.ts
@@ -0,0 +1,200 @@
+/*! SPDX-License-Identifier: GPL-2.0
+ *
+ * Copyright (C) 2015-2020 Jason A. Donenfeld . 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),
+ };
+}