From bc4aac10aa166dcf747fab6c4aac87ef717442a8 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 1 Oct 2025 19:41:08 -0300 Subject: [PATCH] Add browser client support (#490) * Sync wasm rdp and ssh client * sync package-lock * remove msp ref * add ephemeral info --- .github/workflows/build_and_push.yml | 27 + .gitignore | 5 + docker/default.conf | 4 + docker/nginx.conf | 1 + package-lock.json | 17 + package.json | 2 + public/wasm_exec.js | 575 +++++++++++++ src/app/(dashboard)/peer/page.tsx | 13 + src/app/(remote-access)/layout.tsx | 9 + src/app/(remote-access)/peer/rdp/page.tsx | 212 +++++ src/app/(remote-access)/peer/ssh/page.tsx | 233 +++++ src/app/globals.css | 11 + src/auth/OIDCProvider.tsx | 2 + src/components/Button.tsx | 2 +- src/components/Callout.tsx | 2 +- src/components/PeerGroupSelector.tsx | 276 ++++-- src/components/PeerSelector.tsx | 21 +- src/components/VirtualScrollAreaList.tsx | 12 +- .../table/DataTableRefreshButton.tsx | 2 +- .../table/DataTableResetFilterButton.tsx | 2 +- src/components/ui/MemoizedNetBirdIcon.tsx | 2 +- src/components/ui/ResourceBadge.tsx | 50 +- src/components/ui/TextWithTooltip.tsx | 2 +- src/contexts/PeerProvider.tsx | 15 +- src/interfaces/Peer.ts | 2 + src/interfaces/Policy.ts | 2 +- src/layouts/Header.tsx | 2 +- .../access-control/AccessControlModal.tsx | 101 ++- .../table/AccessControlDestinationsCell.tsx | 33 +- .../table/AccessControlResourceCell.tsx | 34 + .../table/AccessControlSourcesCell.tsx | 10 +- .../table/AccessControlTable.tsx | 188 ++-- .../access-control/useAccessControl.ts | 12 +- src/modules/activity/ActivityDescription.tsx | 8 + .../common-table-rows/ActiveInactiveRow.tsx | 5 +- src/modules/peer/EphemeralPeerIndicator.tsx | 21 + .../peer/ExpirationDisabledIndicator.tsx | 23 + src/modules/peer/LoginRequiredIndicator.tsx | 27 + src/modules/peers/PeerActionCell.tsx | 2 +- src/modules/peers/PeerAddressCell.tsx | 6 +- src/modules/peers/PeerConnectButton.tsx | 80 ++ src/modules/peers/PeerNameCell.tsx | 14 +- src/modules/peers/PeerOperatingSystemIcon.tsx | 28 + src/modules/peers/PeerStatusCell.tsx | 82 +- src/modules/peers/PeerVersionCell.tsx | 118 ++- src/modules/peers/PeersTable.tsx | 73 +- src/modules/remote-access/rdp/RDPButton.tsx | 72 ++ .../remote-access/rdp/RDPCertificateModal.tsx | 170 ++++ .../remote-access/rdp/RDPCredentialsModal.tsx | 200 +++++ src/modules/remote-access/rdp/RDPTooltip.tsx | 37 + .../rdp/ironrdp-input-handler.ts | 809 ++++++++++++++++++ .../remote-access/rdp/ironrdp-wasm-bridge.ts | 450 ++++++++++ .../rdp/rdp-certificate-handler.ts | 397 +++++++++ .../rdp/useRDPCertificateHandler.ts | 320 +++++++ .../remote-access/rdp/useRDPQueryParams.ts | 58 ++ .../remote-access/rdp/useRemoteDesktop.ts | 325 +++++++ .../remote-access/rdp/websocket-proxy.ts | 206 +++++ src/modules/remote-access/ssh/SSHButton.tsx | 76 ++ .../remote-access/ssh/SSHCredentialsModal.tsx | 155 ++++ src/modules/remote-access/ssh/SSHTooltip.tsx | 55 ++ src/modules/remote-access/ssh/Terminal.tsx | 172 ++++ src/modules/remote-access/ssh/useSSH.ts | 85 ++ .../remote-access/ssh/useSSHQueryParams.ts | 70 ++ src/modules/remote-access/useNetBirdClient.ts | 306 +++++++ src/utils/helpers.ts | 31 + src/utils/version.ts | 10 + src/utils/wireguard.ts | 200 +++++ 67 files changed, 6239 insertions(+), 333 deletions(-) create mode 100644 public/wasm_exec.js create mode 100644 src/app/(remote-access)/layout.tsx create mode 100644 src/app/(remote-access)/peer/rdp/page.tsx create mode 100644 src/app/(remote-access)/peer/ssh/page.tsx create mode 100644 src/modules/access-control/table/AccessControlResourceCell.tsx create mode 100644 src/modules/peer/EphemeralPeerIndicator.tsx create mode 100644 src/modules/peer/ExpirationDisabledIndicator.tsx create mode 100644 src/modules/peer/LoginRequiredIndicator.tsx create mode 100644 src/modules/peers/PeerConnectButton.tsx create mode 100644 src/modules/peers/PeerOperatingSystemIcon.tsx create mode 100644 src/modules/remote-access/rdp/RDPButton.tsx create mode 100644 src/modules/remote-access/rdp/RDPCertificateModal.tsx create mode 100644 src/modules/remote-access/rdp/RDPCredentialsModal.tsx create mode 100644 src/modules/remote-access/rdp/RDPTooltip.tsx create mode 100644 src/modules/remote-access/rdp/ironrdp-input-handler.ts create mode 100644 src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts create mode 100644 src/modules/remote-access/rdp/rdp-certificate-handler.ts create mode 100644 src/modules/remote-access/rdp/useRDPCertificateHandler.ts create mode 100644 src/modules/remote-access/rdp/useRDPQueryParams.ts create mode 100644 src/modules/remote-access/rdp/useRemoteDesktop.ts create mode 100644 src/modules/remote-access/rdp/websocket-proxy.ts create mode 100644 src/modules/remote-access/ssh/SSHButton.tsx create mode 100644 src/modules/remote-access/ssh/SSHCredentialsModal.tsx create mode 100644 src/modules/remote-access/ssh/SSHTooltip.tsx create mode 100644 src/modules/remote-access/ssh/Terminal.tsx create mode 100644 src/modules/remote-access/ssh/useSSH.ts create mode 100644 src/modules/remote-access/ssh/useSSHQueryParams.ts create mode 100644 src/modules/remote-access/useNetBirdClient.ts create mode 100644 src/utils/wireguard.ts 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 ( +
+
+ + {message} +
+
+ ); +}; + +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(); + }} + > + + + +
+ +
+
+ {res.ip} + +
+
+
+ ); + }} + /> +
+ ); +}; \ 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) { }} > - + <> + + + + + )} {tab == "posture_checks" && ( - - )} - - {tab == "policy" && ( - - )} - - {tab == "posture_checks" && ( - + <> + + + )} {tab == "general" && ( diff --git a/src/modules/access-control/table/AccessControlDestinationsCell.tsx b/src/modules/access-control/table/AccessControlDestinationsCell.tsx index bc6c918..7d07a10 100644 --- a/src/modules/access-control/table/AccessControlDestinationsCell.tsx +++ b/src/modules/access-control/table/AccessControlDestinationsCell.tsx @@ -1,11 +1,9 @@ import MultipleGroups from "@components/ui/MultipleGroups"; -import ResourceBadge from "@components/ui/ResourceBadge"; -import useFetchApi from "@utils/api"; import React, { useMemo } from "react"; -import Skeleton from "react-loading-skeleton"; import { Group } from "@/interfaces/Group"; -import { NetworkResource } from "@/interfaces/Network"; -import { Policy, PolicyRuleResource } from "@/interfaces/Policy"; +import { Policy } from "@/interfaces/Policy"; +import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; type Props = { policy: Policy; @@ -20,30 +18,13 @@ export default function AccessControlDestinationsCell({ if (firstRule?.destinationResource) { return ( - + ); } return firstRule ? ( - ) : null; + ) : ( + + ); } - -const AccessControlDestinationResourceCell = ({ - resource, -}: { - resource: PolicyRuleResource; -}) => { - const { data: resources, isLoading } = useFetchApi( - "/networks/resources", - ); - if (isLoading) return ; - - return ( -
- r.id === resource.id)} /> -
- ); -}; diff --git a/src/modules/access-control/table/AccessControlResourceCell.tsx b/src/modules/access-control/table/AccessControlResourceCell.tsx new file mode 100644 index 0000000..775e11c --- /dev/null +++ b/src/modules/access-control/table/AccessControlResourceCell.tsx @@ -0,0 +1,34 @@ +import ResourceBadge from "@components/ui/ResourceBadge"; +import useFetchApi from "@utils/api"; +import * as React from "react"; +import Skeleton from "react-loading-skeleton"; +import { NetworkResource } from "@/interfaces/Network"; +import { Peer } from "@/interfaces/Peer"; +import { PolicyRuleResource } from "@/interfaces/Policy"; + +type Props = { + resource?: PolicyRuleResource; +}; + +export const AccessControlResourceCell = ({ resource }: Props) => { + const { data: resources, isLoading: isLoadingResources } = useFetchApi< + NetworkResource[] + >("/networks/resources"); + const { data: peers, isLoading: isLoadingPeers } = + useFetchApi("/peers"); + + const isPeer = resource?.type === "peer"; + const peer = peers?.find((p) => p.id === resource?.id); + + if ((isPeer && isLoadingPeers) || (!isPeer && isLoadingResources)) + return ; + + return ( +
+ r.id === resource?.id)} + peer={peer} + /> +
+ ); +}; diff --git a/src/modules/access-control/table/AccessControlSourcesCell.tsx b/src/modules/access-control/table/AccessControlSourcesCell.tsx index ee93795..69c49ec 100644 --- a/src/modules/access-control/table/AccessControlSourcesCell.tsx +++ b/src/modules/access-control/table/AccessControlSourcesCell.tsx @@ -2,6 +2,8 @@ import MultipleGroups from "@components/ui/MultipleGroups"; import React, { useMemo } from "react"; import { Group } from "@/interfaces/Group"; import { Policy } from "@/interfaces/Policy"; +import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell"; +import EmptyRow from "@/modules/common-table-rows/EmptyRow"; type Props = { policy: Policy; @@ -12,7 +14,13 @@ export default function AccessControlSourcesCell({ policy }: Props) { return undefined; }, [policy]); + if (firstRule?.sourceResource) { + return ; + } + return firstRule ? ( - ) : null; + ) : ( + + ); } diff --git a/src/modules/access-control/table/AccessControlTable.tsx b/src/modules/access-control/table/AccessControlTable.tsx index b0a29e8..0d43d04 100644 --- a/src/modules/access-control/table/AccessControlTable.tsx +++ b/src/modules/access-control/table/AccessControlTable.tsx @@ -9,9 +9,9 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage"; import GetStartedTest from "@components/ui/GetStartedTest"; import type { ColumnDef, SortingState } from "@tanstack/react-table"; import { removeAllSpaces } from "@utils/helpers"; -import { ExternalLinkIcon, PlusCircle } from "lucide-react"; +import { ClockFadingIcon, ExternalLinkIcon, PlusCircle } from "lucide-react"; import { usePathname, useSearchParams } from "next/navigation"; -import React, { useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useSWRConfig } from "swr"; import AccessControlIcon from "@/assets/icons/AccessControlIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; @@ -29,6 +29,7 @@ import AccessControlPortsCell from "@/modules/access-control/table/AccessControl import AccessControlPostureCheckCell from "@/modules/access-control/table/AccessControlPostureCheckCell"; import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell"; import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell"; +import FullTooltip from "@components/FullTooltip"; type Props = { policies?: Policy[]; @@ -200,6 +201,41 @@ export default function AccessControlTable({ const [currentRow, setCurrentRow] = useState(); const [currentCellClicked, setCurrentCellClicked] = useState(""); + const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false); + + const withTemporaryPolicies = useCallback( + (condition: boolean) => + policies?.filter((policy) => + condition + ? policy?.name?.startsWith("Temporary") && + policy?.name?.endsWith("client") && + policy?.description?.startsWith("Temporary") && + policy?.description?.endsWith("client") + : !( + policy?.name?.startsWith("Temporary") && + policy?.name?.endsWith("client") && + policy?.description?.startsWith("Temporary") && + policy?.description?.endsWith("client") + ), + ) ?? [], + [policies], + ); + + const tempPolicies = useMemo( + () => withTemporaryPolicies(true), + [withTemporaryPolicies], + ); + const regularPolicies = useMemo( + () => withTemporaryPolicies(false), + [withTemporaryPolicies], + ); + + useEffect(() => { + if (showTemporaryPolicies && tempPolicies?.length === 0) { + setShowTemporaryPolicies(false); + } + }, [showTemporaryPolicies, tempPolicies]); + return ( <> {editModal && currentRow && ( @@ -232,8 +268,9 @@ export default function AccessControlTable({ columnVisibility={{ description: false, id: false, + temporary: false, }} - data={policies} + data={showTemporaryPolicies ? tempPolicies : regularPolicies} onRowClick={(row, cell) => { setCurrentRow(row.original); setEditModal(true); @@ -301,66 +338,91 @@ export default function AccessControlTable({ )} > - {(table) => ( - <> - - { - table.setPageIndex(0); - table.getColumn("enabled")?.setFilterValue(undefined); - }} - disabled={policies?.length == 0} - variant={ - table.getColumn("enabled")?.getFilterValue() === undefined - ? "tertiary" - : "secondary" - } - > - All - - { - table.setPageIndex(0); - table.getColumn("enabled")?.setFilterValue(true); - }} - disabled={policies?.length == 0} - variant={ - table.getColumn("enabled")?.getFilterValue() === true - ? "tertiary" - : "secondary" - } - > - Active - - { - table.setPageIndex(0); - table.getColumn("enabled")?.setFilterValue(false); - }} - disabled={policies?.length == 0} - variant={ - table.getColumn("enabled")?.getFilterValue() === false - ? "tertiary" - : "secondary" - } - > - Inactive - - - - { - mutate("/policies").then(); - mutate("/groups").then(); - }} - /> - - )} + {(table) => { + return ( + <> + + { + table.setPageIndex(0); + table.getColumn("enabled")?.setFilterValue(undefined); + }} + disabled={policies?.length == 0} + variant={ + table.getColumn("enabled")?.getFilterValue() === undefined + ? "tertiary" + : "secondary" + } + > + All + + { + table.setPageIndex(0); + table.getColumn("enabled")?.setFilterValue(true); + }} + disabled={policies?.length == 0} + variant={ + table.getColumn("enabled")?.getFilterValue() === true + ? "tertiary" + : "secondary" + } + > + Active + + { + table.setPageIndex(0); + table.getColumn("enabled")?.setFilterValue(false); + }} + disabled={policies?.length == 0} + variant={ + table.getColumn("enabled")?.getFilterValue() === false + ? "tertiary" + : "secondary" + } + > + Inactive + + + + + {tempPolicies?.length > 0 && ( + + Show temporary policies created by the NetBird browser + client. These policies are ephemeral and will be + deleted automatically after a short period of time. +
+ } + > + + + )} + + { + mutate("/policies").then(); + mutate("/groups").then(); + }} + /> + + ); + }} ); -} +} \ No newline at end of file diff --git a/src/modules/access-control/useAccessControl.ts b/src/modules/access-control/useAccessControl.ts index 39d794c..60db972 100644 --- a/src/modules/access-control/useAccessControl.ts +++ b/src/modules/access-control/useAccessControl.ts @@ -126,6 +126,10 @@ export const useAccessControl = ({ : initialDestinationGroups ?? [], }); + const [sourceResource, setSourceResource] = useState( + firstRule?.sourceResource, + ); + const [destinationResource, setDestinationResource] = useState( firstRule?.destinationResource, ); @@ -163,8 +167,9 @@ export const useAccessControl = ({ bidirectional: direction == "bi", description, name, - sources: sources, + sources: sourceResource ? undefined : sources, destinations: destinationResource ? undefined : destinations, + sourceResource: sourceResource || undefined, destinationResource: destinationResource || undefined, action: "accept", protocol, @@ -241,8 +246,9 @@ export const useAccessControl = ({ action: "accept", protocol, enabled, - sources, + sources: sourceResource ? undefined : sources, destinations: destinationResource ? undefined : destinations, + sourceResource: sourceResource || undefined, destinationResource: destinationResource || undefined, ports: newPorts, port_ranges: newPortRanges, @@ -345,6 +351,8 @@ export const useAccessControl = ({ getPolicyData, portDisabled, isPostureChecksLoading, + sourceResource, + setSourceResource, destinationResource, setDestinationResource, destinationHasResources, diff --git a/src/modules/activity/ActivityDescription.tsx b/src/modules/activity/ActivityDescription.tsx index fb4c6fd..790b02e 100644 --- a/src/modules/activity/ActivityDescription.tsx +++ b/src/modules/activity/ActivityDescription.tsx @@ -365,6 +365,14 @@ export default function ActivityDescription({ event }: 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 ( + + ); +}; 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 - - - -
- ) : ( -
- {!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 && ( + + )} +
+ ) + ); +} \ 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. +
+ } + > + + + )} + { 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 +
+
+ ) : ( + + )} +
+
+ + ) + ); +}; 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. + + +
+ + +
+ + +
+ + +
+
+
+
+ ); +}; + +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"} + /> + + +
{ + e.preventDefault(); + handleConnect(); + }} + > + {error && ( +
+
+ Error +
+

{error}

+
+ )} +
+ + + Enter the credentials required to authenticate with the remote + host. + +
+ setUsername(e.target.value)} + onKeyDown={handleKeyDown} + name="username" + autoComplete={"username"} + error={userNameError} + errorTooltip={true} + errorTooltipPosition={"top-right"} + customPrefix={ + + } + /> + setPassword(e.target.value)} + onKeyDown={handleKeyDown} + name="password" + autoComplete={"current-password"} + error={undefined} + errorTooltip={true} + errorTooltipPosition={"top-right"} + customPrefix={ + + } + /> +
+
+
+ + + Specify the RDP port for your remote connection. + + setPort(e.target.value)} + onKeyDown={handleKeyDown} + customPrefix={ + + } + /> +
+
+ + +
+ + Learn more about + + RDP + + + +
+
+ +
+
+
+
+ ); +}; 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:

+
+ + + + ${certInfo.serialNumber ? `` : ''} + +
Subject:${certInfo.subject || 'Unknown'}
Issuer:${certInfo.issuer || 'Unknown'}
Serial:${certInfo.serialNumber}
SHA-256: + ${certInfo.fingerprint}
+
+
+

Do you trust this certificate?

+ +
+
+ + +
+
+ `; + // 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 +
+
+ ) : ( + + )} +
+
+ + ) + ); +}; 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"} + /> + + +
+
+ + + The username and port you will use to connect to the remote host. + +
+ setUsername(e.target.value)} + customSuffix={`@${peer.ip}`} + data-1p-ignore + autoComplete={"off"} + error={userNameError} + errorTooltip={true} + errorTooltipPosition={"top-right"} + customPrefix={ + + } + /> + setPort(e.target.value)} + customPrefix={ + + } + /> +
+
+
+ + +
+ + Learn more about + + SSH + + + +
+
+ + + + + +
+
+
+
+ ); +}; 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), + }; +}