mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-01-26 01:21:04 +00:00
Add browser client support (#490)
* Sync wasm rdp and ssh client * sync package-lock * remove msp ref * add ephemeral info
This commit is contained in:
27
.github/workflows/build_and_push.yml
vendored
27
.github/workflows/build_and_push.yml
vendored
@@ -28,6 +28,33 @@ jobs:
|
||||
|
||||
- run: echo '{}' > .local-config.json
|
||||
|
||||
- name: Download IronRDP release TS files
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "*.ts"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Download IronRDP release JS files
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "*.js"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Download IronRDP release WASM file
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
-
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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*
|
||||
@@ -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;
|
||||
|
||||
@@ -101,6 +101,7 @@ http {
|
||||
application/rss+xml
|
||||
application/vnd.geo+json
|
||||
application/vnd.ms-fontobject
|
||||
application/wasm
|
||||
application/x-font-ttf
|
||||
application/x-web-app-manifest+json
|
||||
application/xhtml+xml
|
||||
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"autoprefixer": "^10",
|
||||
"chart.js": "^4.4.8",
|
||||
"chroma-js": "^3.1.2",
|
||||
|
||||
575
public/wasm_exec.js
Normal file
575
public/wasm_exec.js
Normal file
@@ -0,0 +1,575 @@
|
||||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -33,6 +33,7 @@ import dayjs from "dayjs";
|
||||
import { isEmpty, trim } from "lodash";
|
||||
import {
|
||||
Barcode,
|
||||
CalendarDays,
|
||||
Cpu,
|
||||
FlagIcon,
|
||||
Globe,
|
||||
@@ -65,6 +66,8 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
|
||||
import { PeerExpirationToggle } from "@/modules/peer/PeerExpirationToggle";
|
||||
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
|
||||
export default function PeerPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -347,6 +350,16 @@ const PeerGeneralInformation = () => {
|
||||
/>
|
||||
</FullTooltip>
|
||||
|
||||
{/* Remote Access Buttons */}
|
||||
<div>
|
||||
<Label>Remote Access</Label>
|
||||
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
|
||||
<div className="flex gap-3">
|
||||
<SSHButton peer={peer} />
|
||||
<RDPButton peer={peer} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{permission.groups.read && (
|
||||
<div>
|
||||
<Label>Assigned Groups</Label>
|
||||
|
||||
9
src/app/(remote-access)/layout.tsx
Normal file
9
src/app/(remote-access)/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import UsersProvider from "@/contexts/UsersProvider";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<UsersProvider>{children}</UsersProvider>
|
||||
);
|
||||
}
|
||||
212
src/app/(remote-access)/peer/rdp/page.tsx
Normal file
212
src/app/(remote-access)/peer/rdp/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { notify } from "@components/Notification";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
|
||||
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
|
||||
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
|
||||
import {
|
||||
RDPCredentials,
|
||||
RDPStatus,
|
||||
useRemoteDesktop,
|
||||
} from "@/modules/remote-access/rdp/useRemoteDesktop";
|
||||
import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
export default function RDPPage() {
|
||||
const { peerId } = useRDPQueryParams();
|
||||
|
||||
const {
|
||||
data: peer,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
|
||||
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden"}>
|
||||
{peerId && peer && !isLoading ? (
|
||||
<RDPSession key={peer.id} peer={peer} />
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
function RDPSession({ peer }: Props) {
|
||||
const client = useNetBirdClient();
|
||||
const [isNetBirdConnecting, setIsNetBirdConnecting] = useState(false);
|
||||
const rdp = useRemoteDesktop(client);
|
||||
const [credentialsModal, setCredentialsModal] = useState(true);
|
||||
const [credentials, setCredentials] = useState<RDPCredentials | null>(null);
|
||||
const connected = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${peer.name} - ${peer.ip} - RDP`;
|
||||
}, []);
|
||||
|
||||
const sendErrorNotification = (title: string, message: string) => {
|
||||
notify({
|
||||
title: title,
|
||||
description: message,
|
||||
icon: <IconCircleX size={24} />,
|
||||
backgroundColor: "bg-red-500",
|
||||
duration: 10000,
|
||||
});
|
||||
};
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
setCredentials(null);
|
||||
connected.current = false;
|
||||
setCredentialsModal(true);
|
||||
rdp.session?.disconnect();
|
||||
await client.disconnect();
|
||||
}, [client, rdp]);
|
||||
|
||||
/**
|
||||
* Establishes a connection to the peer
|
||||
*/
|
||||
const connect = async (rdpCredentials: RDPCredentials) => {
|
||||
if (!peer?.id) return;
|
||||
if (client.status === NetBirdStatus.DISCONNECTED) {
|
||||
try {
|
||||
setCredentials(rdpCredentials);
|
||||
setIsNetBirdConnecting(true);
|
||||
await client.connectTemporary(peer.id, [`tcp/${rdpCredentials.port}`]);
|
||||
setIsNetBirdConnecting(false);
|
||||
} catch (error) {
|
||||
sendErrorNotification(
|
||||
"NetBird Connection Error",
|
||||
(error as Error).message,
|
||||
);
|
||||
setIsNetBirdConnecting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startSession = useCallback(async () => {
|
||||
if (!credentials) return;
|
||||
try {
|
||||
const result = await rdp.connect({
|
||||
hostname: peer.ip,
|
||||
port: credentials.port,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
if (result === RDPStatus.CONNECTED) {
|
||||
connected.current = true;
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
sendErrorNotification("RDP Connection Error", (error as Error).message);
|
||||
setCredentialsModal(true);
|
||||
await reset();
|
||||
}
|
||||
}, [credentials, peer.ip, rdp, reset]);
|
||||
|
||||
/**
|
||||
* Establish RDP session when NetBird connection is ready
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (
|
||||
client.status === NetBirdStatus.CONNECTED &&
|
||||
rdp.status === RDPStatus.DISCONNECTED &&
|
||||
credentials &&
|
||||
!connected.current &&
|
||||
!isNetBirdConnecting
|
||||
) {
|
||||
startSession().catch(console.error);
|
||||
}
|
||||
}, [
|
||||
client.status,
|
||||
credentials,
|
||||
peer.ip,
|
||||
rdp,
|
||||
startSession,
|
||||
isNetBirdConnecting,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Display notifications for RDP and NetBird client errors
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (rdp.error) {
|
||||
sendErrorNotification("RDP Error", rdp.error);
|
||||
}
|
||||
if (client.error) {
|
||||
sendErrorNotification("NetBird Client Error", client.error);
|
||||
}
|
||||
}, [rdp, client]);
|
||||
|
||||
/**
|
||||
* Close credentials modal when RDP is connected
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (rdp.status === RDPStatus.CONNECTED) {
|
||||
setCredentialsModal(false);
|
||||
}
|
||||
}, [rdp.status]);
|
||||
|
||||
const isLoading =
|
||||
client.status === NetBirdStatus.CONNECTING ||
|
||||
rdp.status === RDPStatus.CONNECTING ||
|
||||
rdp.isResizing ||
|
||||
isNetBirdConnecting;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Credentials Modal */}
|
||||
<RDPCredentialsModal
|
||||
open={credentialsModal}
|
||||
peer={peer}
|
||||
onConnect={connect}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
{/* Certificate Modal */}
|
||||
<RDPCertificateModal
|
||||
open={!!rdp.pendingCertificate}
|
||||
certificateInfo={rdp.pendingCertificate}
|
||||
onAccept={rdp.acceptCertificatePrompt}
|
||||
onReject={async () => {
|
||||
rdp.rejectCertificatePrompt();
|
||||
await reset();
|
||||
}}
|
||||
/>
|
||||
|
||||
{rdp.isResizing && (
|
||||
<div
|
||||
className={
|
||||
"fixed w-screen h-screen z-50 backdrop-blur bg-black/50 flex items-center justify-center"
|
||||
}
|
||||
>
|
||||
<Loader2Icon size={20} className={"animate-spin"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RDP Canvas */}
|
||||
<canvas
|
||||
ref={rdp.canvasRef}
|
||||
className={cn(
|
||||
rdp.status === RDPStatus.CONNECTED ? "block" : "hidden",
|
||||
"w-full h-full select-none bg-nb-gray-950",
|
||||
)}
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
233
src/app/(remote-access)/peer/ssh/page.tsx
Normal file
233
src/app/(remote-access)/peer/ssh/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { PageNotFound } from "@components/ui/PageNotFound";
|
||||
import useFetchApi, { ErrorResponse } from "@utils/api";
|
||||
import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { Terminal } from "@/modules/remote-access/ssh/Terminal";
|
||||
import { SSHStatus, useSSH } from "@/modules/remote-access/ssh/useSSH";
|
||||
import { useSSHQueryParams } from "@/modules/remote-access/ssh/useSSHQueryParams";
|
||||
import {
|
||||
NetBirdStatus,
|
||||
useNetBirdClient,
|
||||
} from "@/modules/remote-access/useNetBirdClient";
|
||||
|
||||
export default function SSHPage() {
|
||||
const { peerId, username, port } = useSSHQueryParams();
|
||||
|
||||
const {
|
||||
data: peer,
|
||||
isLoading,
|
||||
error,
|
||||
} = useFetchApi<Peer>(`/peers/${peerId}`, true, false, !!peerId);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden"}>
|
||||
<ErrorMessage
|
||||
error={{
|
||||
message:
|
||||
"This peer may have been deleted, or you may not have permission to view it.",
|
||||
code: error.code,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={"w-screen h-screen overflow-hidden"}>
|
||||
{peerId && peer && !isLoading && username && port ? (
|
||||
<SSHTerminal
|
||||
key={peer.id}
|
||||
peer={peer}
|
||||
username={username}
|
||||
port={port}
|
||||
/>
|
||||
) : (
|
||||
<LoadingMessage message={"Starting ssh session..."} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
username: string;
|
||||
port: string;
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
function SSHTerminal({ username, port, peer }: Props) {
|
||||
const client = useNetBirdClient();
|
||||
const connected = useRef(false);
|
||||
const sshConnectedOnce = useRef(false);
|
||||
|
||||
const {
|
||||
connect: ssh,
|
||||
disconnect,
|
||||
status,
|
||||
session,
|
||||
error: sshError,
|
||||
} = useSSH(client);
|
||||
|
||||
const isSSHConnecting = status === SSHStatus.CONNECTING;
|
||||
const isSSHConnected = status === SSHStatus.CONNECTED;
|
||||
const isSSHDisconnected = status === SSHStatus.DISCONNECTED;
|
||||
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
|
||||
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
|
||||
}, [username, peer, client]);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
if (!peer?.id) return;
|
||||
if (isSSHConnected || isSSHConnecting) return;
|
||||
connected.current = false;
|
||||
try {
|
||||
const rules = [`tcp/${port}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
await ssh({
|
||||
hostname: peer.ip,
|
||||
port: Number(port),
|
||||
username,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Reconnection failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isSSHConnected || isSSHConnecting) return;
|
||||
if (isClientConnecting || client.status === NetBirdStatus.CONNECTED) return;
|
||||
|
||||
const connect = async () => {
|
||||
if (!peer.id) return;
|
||||
if (connected.current) return;
|
||||
connected.current = true;
|
||||
try {
|
||||
const rules = [`tcp/${port}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
const res = await ssh({
|
||||
hostname: peer.ip,
|
||||
port: Number(port),
|
||||
username,
|
||||
});
|
||||
if (res === SSHStatus.CONNECTED) {
|
||||
sshConnectedOnce.current = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Connection failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isClientDisconnected) connect().catch(console.error);
|
||||
}, [
|
||||
isClientDisconnected,
|
||||
isSSHConnected,
|
||||
isSSHConnecting,
|
||||
isClientConnecting,
|
||||
peer.id,
|
||||
port,
|
||||
ssh,
|
||||
username,
|
||||
client.connectTemporary,
|
||||
client.status,
|
||||
]);
|
||||
|
||||
if (client.error) {
|
||||
return <ErrorMessage error={{ message: client.error, code: 0 }} />;
|
||||
}
|
||||
|
||||
if (sshError) {
|
||||
return <ErrorMessage error={{ message: sshError, code: 0 }} />;
|
||||
}
|
||||
|
||||
if (isSSHDisconnected && sshConnectedOnce.current) {
|
||||
return (
|
||||
<DisconnectedMessage
|
||||
username={username}
|
||||
peerIp={peer.ip}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{session && <Terminal session={session} onClose={disconnect} />}
|
||||
{!isSSHConnected && (
|
||||
<LoadingMessage message={`Connecting to ${username}@${peer.ip}...`} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MessageProps = {
|
||||
message?: string;
|
||||
error?: ErrorResponse;
|
||||
};
|
||||
|
||||
const LoadingMessage = ({ message }: MessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-full flex items-center justify-center flex-col text-center"
|
||||
}
|
||||
>
|
||||
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
|
||||
<Loader2Icon size={16} className={"animate-spin shrink-0"} />
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorMessage = ({ error }: MessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-full flex items-center justify-center flex-col text-center"
|
||||
}
|
||||
>
|
||||
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
|
||||
<CircleXIcon size={16} className={"shrink-0 text-red-500"} />
|
||||
{error?.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DisconnectedMessageProps = {
|
||||
username: string;
|
||||
peerIp: string;
|
||||
onReconnect: () => void;
|
||||
};
|
||||
|
||||
const DisconnectedMessage = ({
|
||||
username,
|
||||
peerIp,
|
||||
onReconnect,
|
||||
}: DisconnectedMessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"w-full h-full flex items-center justify-center flex-col text-center gap-4"
|
||||
}
|
||||
>
|
||||
<div className="text-nb-gray-200 font-normal text-base flex gap-2 items-center justify-center">
|
||||
<InfoIcon size={16} className={"shrink-0 text-nb-gray-200"} />
|
||||
Disconnected from {username}@{peerIp}
|
||||
<button
|
||||
className={
|
||||
"underline-offset-4 items-center transition-all duration-200 inline-flex texts-inherit gap-1 text-netbird hover:underline font-normal"
|
||||
}
|
||||
onClick={onReconnect}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export default function OIDCProvider({ children }: Props) {
|
||||
"utm_content",
|
||||
"utm_campaign",
|
||||
"hs_id",
|
||||
"user",
|
||||
"port",
|
||||
];
|
||||
|
||||
try {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -26,7 +26,7 @@ export const calloutVariants = cva(
|
||||
|
||||
export const Callout = ({
|
||||
children,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[2px]"} />,
|
||||
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
|
||||
className,
|
||||
variant = "default",
|
||||
}: Props) => {
|
||||
|
||||
@@ -43,6 +43,13 @@ import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item?.id?.toLowerCase().includes(lowerCaseQuery) ?? false;
|
||||
};
|
||||
|
||||
interface MultiSelectProps {
|
||||
values: Group[];
|
||||
@@ -60,6 +67,7 @@ interface MultiSelectProps {
|
||||
dataCy?: string;
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
@@ -67,6 +75,7 @@ interface MultiSelectProps {
|
||||
align?: "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
users?: User[];
|
||||
placeholderForSearch?: string;
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -84,6 +93,7 @@ export function PeerGroupSelector({
|
||||
dataCy = "group-selector-dropdown",
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
resource,
|
||||
onResourceChange,
|
||||
placeholder = "Add or select group(s)...",
|
||||
@@ -91,16 +101,35 @@ export function PeerGroupSelector({
|
||||
align = "start",
|
||||
side = "bottom",
|
||||
users,
|
||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const { groups, dropdownOptions, setDropdownOptions, addDropdownOptions } =
|
||||
useGroups();
|
||||
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const [inputRef, { width }] = useElementSize<
|
||||
HTMLButtonElement | HTMLSpanElement
|
||||
>();
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const sortedDropdownOptions = useSortedDropdownOptions(
|
||||
dropdownOptions,
|
||||
values,
|
||||
open,
|
||||
);
|
||||
|
||||
const [filteredGroups, search, setSearch] = useSearch(
|
||||
sortedDropdownOptions,
|
||||
groupsSearchPredicate,
|
||||
{ filter: true, debounce: 150 },
|
||||
);
|
||||
|
||||
// Update dropdown options when groups change
|
||||
@@ -189,16 +218,6 @@ export function PeerGroupSelector({
|
||||
return isSearching && groupDoesNotExist && !isAllGroup;
|
||||
}, [search, dropdownOptions]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const folderIcon = useMemo(() => {
|
||||
return <FolderGit2 size={12} className={"shrink-0"} />;
|
||||
}, []);
|
||||
|
||||
const peerIcon = useMemo(() => {
|
||||
return <MonitorSmartphoneIcon size={14} className={"shrink-0"} />;
|
||||
}, []);
|
||||
|
||||
const [slice, setSlice] = useState(10);
|
||||
|
||||
const [tab, setTab] = useState("groups");
|
||||
@@ -219,12 +238,6 @@ export function PeerGroupSelector({
|
||||
onChange(union);
|
||||
};
|
||||
|
||||
const sortedDropdownOptions = useSortedDropdownOptions(
|
||||
dropdownOptions,
|
||||
values,
|
||||
open,
|
||||
);
|
||||
|
||||
// Reset the search input when switching tabs
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
@@ -233,10 +246,12 @@ export function PeerGroupSelector({
|
||||
}, 0);
|
||||
}, [tab]);
|
||||
|
||||
const searchPlaceholder =
|
||||
tab === "groups"
|
||||
? 'Search groups or add new group by pressing "Enter"...'
|
||||
: "Search resource...";
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (tab === "groups") return placeholderForSearch;
|
||||
if (tab === "resources") return "Search resource...";
|
||||
if (tab === "peers") return "Search peer...";
|
||||
return "Search...";
|
||||
}, [tab, placeholderForSearch]);
|
||||
|
||||
const selectResource = (resource?: NetworkResource) => {
|
||||
onResourceChange?.(
|
||||
@@ -250,6 +265,15 @@ export function PeerGroupSelector({
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
const selectPeer = (peer?: Peer) => {
|
||||
if (!peer?.id) return;
|
||||
onResourceChange?.({
|
||||
id: peer.id,
|
||||
type: "peer",
|
||||
});
|
||||
onChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
@@ -288,6 +312,7 @@ export function PeerGroupSelector({
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
peer={peers?.find((p) => p.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -364,16 +389,7 @@ export function PeerGroupSelector({
|
||||
side={side}
|
||||
sideOffset={10}
|
||||
>
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
if (formatValue.includes(formatSearch)) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<Command className={"w-full flex"} loop shouldFilter={false}>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
@@ -414,13 +430,17 @@ export function PeerGroupSelector({
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={"groups"} value={tab} onValueChange={setTab}>
|
||||
{showResources && <TabTriggers searchRef={searchRef} />}
|
||||
<TabTriggers
|
||||
searchRef={searchRef}
|
||||
showPeers={showPeers}
|
||||
showResources={showResources}
|
||||
/>
|
||||
<TabsContent value={"groups"} className={"p-0 my-0"}>
|
||||
<CommandGroup>
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"max-h-[195px] flex flex-col gap-1 pl-2 py-2 pr-3",
|
||||
sortedDropdownOptions.length == 0 && !search && "py-0",
|
||||
filteredGroups.length == 0 && !search && "py-0",
|
||||
)}
|
||||
>
|
||||
{searchedGroupNotFound && (
|
||||
@@ -433,8 +453,8 @@ export function PeerGroupSelector({
|
||||
value={search}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge variant={"gray-ghost"}>
|
||||
{folderIcon}
|
||||
<Badge variant={"gray-ghost"} className={"h-7"}>
|
||||
<FolderGit2 size={12} className={"shrink-0"} />
|
||||
{search}
|
||||
</Badge>
|
||||
<div
|
||||
@@ -448,7 +468,7 @@ export function PeerGroupSelector({
|
||||
</CommandItem>
|
||||
)}
|
||||
|
||||
{sortedDropdownOptions.slice(0, slice).map((option) => {
|
||||
{filteredGroups.slice(0, slice).map((option) => {
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
@@ -490,7 +510,11 @@ export function PeerGroupSelector({
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<GroupBadge group={option} showNewBadge={true} />
|
||||
<GroupBadge
|
||||
group={option}
|
||||
showNewBadge={true}
|
||||
className={"h-7"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
@@ -509,7 +533,10 @@ export function PeerGroupSelector({
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{peerIcon}
|
||||
<MonitorSmartphoneIcon
|
||||
size={14}
|
||||
className={"shrink-0"}
|
||||
/>
|
||||
{peerCount} Peer(s)
|
||||
</div>
|
||||
) : (
|
||||
@@ -535,12 +562,23 @@ export function PeerGroupSelector({
|
||||
<ResourcesList
|
||||
search={search}
|
||||
resources={resources}
|
||||
isLoading={isLoading}
|
||||
isLoading={isResourcesLoading}
|
||||
value={resource}
|
||||
onChange={selectResource}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{showPeers && (
|
||||
<TabsContent value={"peers"} className={"p-0 my-0"}>
|
||||
<PeersList
|
||||
search={search}
|
||||
peers={peers}
|
||||
isLoading={isPeersLoading}
|
||||
value={resource}
|
||||
onChange={selectPeer}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -551,9 +589,14 @@ export function PeerGroupSelector({
|
||||
|
||||
const TabTriggers = ({
|
||||
searchRef,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
}: {
|
||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
}) => {
|
||||
if (!showResources && !showPeers) return null;
|
||||
return (
|
||||
<TabsList justify={"start"} className={"px-3"}>
|
||||
<TabsTrigger
|
||||
@@ -569,19 +612,38 @@ const TabTriggers = ({
|
||||
/>
|
||||
Groups
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={"resources"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<Layers3Icon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Resource
|
||||
</TabsTrigger>
|
||||
|
||||
{showResources && (
|
||||
<TabsTrigger
|
||||
value={"resources"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<Layers3Icon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Resources
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
{showPeers && (
|
||||
<TabsTrigger
|
||||
value={"peers"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<MonitorSmartphoneIcon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Peers
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
);
|
||||
};
|
||||
@@ -700,7 +762,7 @@ const ResourcesList = ({
|
||||
useHover={true}
|
||||
data-cy={"group-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap")}
|
||||
className={cn("transition-all group whitespace-nowrap h-7")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
@@ -736,3 +798,107 @@ const ResourcesList = ({
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
const peersSearchPredicate = (item: Peer, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item.ip.toLowerCase().includes(lowerCaseQuery);
|
||||
};
|
||||
|
||||
const PeersList = ({
|
||||
search,
|
||||
peers,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
search: string;
|
||||
peers?: Peer[];
|
||||
isLoading: boolean;
|
||||
value?: PolicyRuleResource;
|
||||
onChange: (peer: Peer) => void;
|
||||
}) => {
|
||||
const [filteredItems, _, setSearch] = useSearch(
|
||||
peers || [],
|
||||
peersSearchPredicate,
|
||||
{ filter: true, debounce: 150 },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(search);
|
||||
}, [search, setSearch]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}>
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
<Skeleton height={42} className={"rounded-md"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (search != "" && filteredItems.length == 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
There are no peers matching your search. Please try a different search
|
||||
term.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
if (search == "" && filteredItems.length == 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
There are no peers available yet. <br />
|
||||
Go to <InlineLink href={"/peers"}>Peers</InlineLink> to add some peers.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Radio defaultValue={value?.id} name={"peer"} value={value?.id}>
|
||||
<VirtualScrollAreaList
|
||||
items={filteredItems}
|
||||
onSelect={onChange}
|
||||
itemClassName={"dark:aria-selected:bg-nb-gray-800/20"}
|
||||
renderItem={(res) => {
|
||||
if (!res?.id) return;
|
||||
|
||||
return (
|
||||
<Fragment key={res.id}>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
useHover={true}
|
||||
data-cy={"group-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn(
|
||||
"transition-all group whitespace-nowrap h-7 px-2",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<PeerOperatingSystemIcon os={res.os} />
|
||||
<TextWithTooltip text={res?.name || ""} maxChars={20} />
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className={"flex items-center gap-5"}>
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{res.ip}
|
||||
<RadioItem value={res.id} />
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import FullTooltip from "@components/FullTooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@components/Popover";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn } from "@utils/helpers";
|
||||
@@ -16,7 +15,7 @@ import { memo, useEffect, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
const MapPinIcon = memo(() => <MapPin size={12} />);
|
||||
MapPinIcon.displayName = "MapPinIcon";
|
||||
@@ -182,7 +181,6 @@ export function PeerSelector({
|
||||
togglePeer(item);
|
||||
}}
|
||||
renderItem={(option) => {
|
||||
const os = getOperatingSystem(option.os);
|
||||
const isSupported = isRoutingPeerSupported(
|
||||
option.version,
|
||||
option.os,
|
||||
@@ -210,19 +208,10 @@ export function PeerSelector({
|
||||
: "text-nb-gray-300",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
os === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
os === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
os === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<OSLogo os={option.os} />
|
||||
</div>
|
||||
|
||||
<PeerOperatingSystemIcon
|
||||
os={option.os}
|
||||
className={isSupported ? "" : "opacity-50"}
|
||||
/>
|
||||
<div className={cn(!isSupported && "opacity-50")}>
|
||||
<TextWithTooltip
|
||||
text={option.name}
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props<T extends { id?: string }> = {
|
||||
renderHeading?: (item: T) => React.ReactNode;
|
||||
renderBeforeItem?: (item: T) => React.ReactNode;
|
||||
itemClassName?: string;
|
||||
itemClassNameWithItem?: (item: T) => string;
|
||||
itemWrapperClassName?: string;
|
||||
scrollAreaClassName?: string;
|
||||
maxHeight?: number;
|
||||
@@ -21,6 +22,7 @@ type Props<T extends { id?: string }> = {
|
||||
estimatedHeadingHeight?: number;
|
||||
heightAdjustment?: number;
|
||||
groupKey?: (item: T) => string | undefined;
|
||||
itemKey?: (item: T) => string;
|
||||
};
|
||||
|
||||
export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
@@ -30,6 +32,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
renderBeforeItem,
|
||||
renderHeading,
|
||||
itemClassName,
|
||||
itemClassNameWithItem,
|
||||
itemWrapperClassName,
|
||||
scrollAreaClassName,
|
||||
maxHeight,
|
||||
@@ -37,6 +40,7 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
estimatedHeadingHeight = 16,
|
||||
heightAdjustment = 8,
|
||||
groupKey,
|
||||
itemKey,
|
||||
}: Readonly<Props<T>>) {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const [lastInputMethod, setLastInputMethod] = useState<"mouse" | "keyboard">(
|
||||
@@ -159,10 +163,14 @@ export function VirtualScrollAreaList<T extends { id?: string }>({
|
||||
setSelected(index);
|
||||
}
|
||||
}}
|
||||
id={option.id}
|
||||
id={itemKey ? itemKey(option) : option?.id}
|
||||
onClick={() => onClick(option)}
|
||||
ariaSelected={selected === index}
|
||||
itemClassName={itemClassName}
|
||||
itemClassName={
|
||||
itemClassNameWithItem
|
||||
? itemClassNameWithItem(option)
|
||||
: itemClassName
|
||||
}
|
||||
className={itemWrapperClassName}
|
||||
isLast={index === items.length - 1}
|
||||
>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function DataTableRefreshButton({ onClick, isDisabled }: Props) {
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
className={"h-[44px]"}
|
||||
variant={"secondary"}
|
||||
disabled={isDisabled == true ? true : disabled}
|
||||
>
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function DataTableResetFilterButton<TData>({
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className={"h-[42px]"}
|
||||
className={"h-[44px]"}
|
||||
variant={"secondary"}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { memo } from "react";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
|
||||
const MemoizedNetBirdIcon = () => {
|
||||
return <NetBirdIcon size={16} />;
|
||||
return <NetBirdIcon size={14} />;
|
||||
};
|
||||
|
||||
export default memo(MemoizedNetBirdIcon);
|
||||
|
||||
@@ -4,9 +4,13 @@ import { cn } from "@utils/helpers";
|
||||
import { GlobeIcon, NetworkIcon, WorkflowIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
peer?: Peer;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
showX?: boolean;
|
||||
children?: React.ReactNode;
|
||||
@@ -15,35 +19,44 @@ type Props = {
|
||||
export default function ResourceBadge({
|
||||
onClick,
|
||||
resource,
|
||||
peer,
|
||||
showX = false,
|
||||
children,
|
||||
className,
|
||||
}: Readonly<Props>) {
|
||||
if (!resource) return;
|
||||
if (!resource && !peer) return;
|
||||
|
||||
const isPeer = !!peer;
|
||||
const key = resource ? resource.id || resource?.name : peer?.id || peer?.name;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={resource.id || resource?.name}
|
||||
key={key}
|
||||
useHover={true}
|
||||
data-cy={"resource-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={cn("transition-all group whitespace-nowrap", className)}
|
||||
className={cn(
|
||||
"transition-all group whitespace-nowrap",
|
||||
className,
|
||||
isPeer && "px-2",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{resource.type === "host" && (
|
||||
<WorkflowIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "domain" && (
|
||||
<GlobeIcon size={12} className={"shrink-0"} />
|
||||
)}
|
||||
{resource.type === "subnet" && (
|
||||
<NetworkIcon size={12} className={"shrink-0"} />
|
||||
{isPeer ? (
|
||||
<>
|
||||
<PeerOperatingSystemIcon os={peer?.os} />
|
||||
<TruncatedText text={peer?.name || ""} maxChars={20} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ResourceIcon type={resource?.type || ""} />
|
||||
<TruncatedText text={resource?.name || ""} maxChars={20} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TruncatedText text={resource?.name || ""} maxChars={20} />
|
||||
{children}
|
||||
{showX && (
|
||||
<XIcon
|
||||
@@ -56,3 +69,16 @@ export default function ResourceBadge({
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ResourceIcon = ({ type }: { type: string }) => {
|
||||
switch (type) {
|
||||
case "host":
|
||||
return <WorkflowIcon size={12} className={"shrink-0"} />;
|
||||
case "domain":
|
||||
return <GlobeIcon size={12} className={"shrink-0"} />;
|
||||
case "subnet":
|
||||
return <NetworkIcon size={12} className={"shrink-0"} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function TextWithTooltip({
|
||||
<FullTooltip
|
||||
disabled={charCount <= maxChars || hideTooltip}
|
||||
interactive={false}
|
||||
className={"truncate w-full min-w-0"}
|
||||
className={"truncate w-auto min-w-0"}
|
||||
skipDelayDuration={350}
|
||||
delayDuration={200}
|
||||
content={
|
||||
|
||||
@@ -97,11 +97,22 @@ export default function PeerProvider({ children, peer }: Props) {
|
||||
const openSSHDialog = async (): Promise<boolean> => {
|
||||
return await confirm({
|
||||
title: `Enable SSH Server for ${peer.name}?`,
|
||||
description:
|
||||
"Experimental feature. Enabling this option allows remote SSH access to this machine from other connected network participants.",
|
||||
description: (
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<div>
|
||||
Enabling this option allows remote SSH access to this machine from
|
||||
other connected network participants.
|
||||
</div>
|
||||
<div>
|
||||
Make sure SSH is allowed in the NetBird Client under{" "}
|
||||
<span className={"text-white"}>Settings → Allow SSH</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
confirmText: "Enable",
|
||||
cancelText: "Cancel",
|
||||
type: "warning",
|
||||
maxWidthClass: "max-w-lg",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Peer {
|
||||
user_id?: string;
|
||||
user?: User;
|
||||
ui_version?: string;
|
||||
kernel_version?: string;
|
||||
dns_label: string;
|
||||
extra_dns_labels?: string[];
|
||||
last_login: Date;
|
||||
@@ -26,4 +27,5 @@ export interface Peer {
|
||||
country_code: string;
|
||||
connection_ip: string;
|
||||
serial_number: string;
|
||||
ephemeral: boolean;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface PortRange {
|
||||
|
||||
export interface PolicyRuleResource {
|
||||
id: string;
|
||||
type: "domain" | "host" | "subnet" | undefined;
|
||||
type?: "domain" | "host" | "subnet" | "peer";
|
||||
}
|
||||
|
||||
export type Protocol = "all" | "tcp" | "udp" | "icmp";
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function NavbarWithDropdown() {
|
||||
<AnnouncementBanner />
|
||||
<div
|
||||
className={cn(
|
||||
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray/50 backdrop-blur-lg sm:px-6",
|
||||
"bg-white px-2 py-4 dark:border-gray-700 dark:bg-nb-gray backdrop-blur-lg sm:px-6",
|
||||
"border-b dark:border-zinc-700/40 px-3 md:px-4 w-full",
|
||||
"flex justify-between items-center transition-all",
|
||||
)}
|
||||
|
||||
@@ -156,6 +156,8 @@ export function AccessControlModalContent({
|
||||
submit,
|
||||
isPostureChecksLoading,
|
||||
getPolicyData,
|
||||
sourceResource,
|
||||
setSourceResource,
|
||||
destinationResource,
|
||||
setDestinationResource,
|
||||
portRanges,
|
||||
@@ -176,15 +178,17 @@ export function AccessControlModalContent({
|
||||
return "policy";
|
||||
});
|
||||
|
||||
const continuePostureChecksDisabled = useMemo(() => {
|
||||
if (sourceGroups.length > 0 && destinationResource) return false;
|
||||
if (sourceGroups.length == 0 || destinationGroups.length == 0) return true;
|
||||
}, [sourceGroups, destinationGroups, destinationResource]);
|
||||
const canContinueToPostureChecks = useMemo(() => {
|
||||
const hasSource = sourceGroups.length > 0 || !!sourceResource;
|
||||
const hasDestination =
|
||||
destinationGroups.length > 0 || !!destinationResource;
|
||||
return hasSource && hasDestination;
|
||||
}, [sourceGroups, destinationGroups, destinationResource, sourceResource]);
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
if (name.length == 0) return true;
|
||||
if (continuePostureChecksDisabled) return true;
|
||||
}, [name, continuePostureChecksDisabled]);
|
||||
if (!canContinueToPostureChecks) return true;
|
||||
}, [name, canContinueToPostureChecks]);
|
||||
|
||||
const handleProtocolChange = (p: Protocol) => {
|
||||
setProtocol(p);
|
||||
@@ -220,11 +224,8 @@ export function AccessControlModalContent({
|
||||
<ArrowRightLeft size={16} />
|
||||
Policy
|
||||
</TabsTrigger>
|
||||
<PostureCheckTabTrigger disabled={continuePostureChecksDisabled} />
|
||||
<TabsTrigger
|
||||
value={"general"}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
<PostureCheckTabTrigger disabled={!canContinueToPostureChecks} />
|
||||
<TabsTrigger value={"general"} disabled={!canContinueToPostureChecks}>
|
||||
<Text
|
||||
size={16}
|
||||
className={
|
||||
@@ -283,14 +284,19 @@ export function AccessControlModalContent({
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"source-group-selector"}
|
||||
popoverWidth={500}
|
||||
placeholder={"Select source(s)..."}
|
||||
showRoutes={true}
|
||||
showResources={false}
|
||||
showPeers={true}
|
||||
showResourceCounter={true}
|
||||
showPeerCount={allowEditPeers}
|
||||
disableInlineRemoveGroup={false}
|
||||
popoverWidth={500}
|
||||
showRoutes={false}
|
||||
onChange={setSourceGroups}
|
||||
values={sourceGroups}
|
||||
onChange={setSourceGroups}
|
||||
resource={sourceResource}
|
||||
onResourceChange={setSourceResource}
|
||||
saveGroupAssignments={useSave}
|
||||
showResourceCounter={false}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
@@ -310,17 +316,19 @@ export function AccessControlModalContent({
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"destination-group-selector"}
|
||||
popoverWidth={500}
|
||||
placeholder={"Select destination(s)..."}
|
||||
showRoutes={true}
|
||||
showResources={true}
|
||||
showPeers={true}
|
||||
showResourceCounter={true}
|
||||
showPeerCount={allowEditPeers}
|
||||
disableInlineRemoveGroup={false}
|
||||
popoverWidth={500}
|
||||
onChange={setDestinationGroups}
|
||||
values={destinationGroups}
|
||||
saveGroupAssignments={useSave}
|
||||
onChange={setDestinationGroups}
|
||||
resource={destinationResource}
|
||||
onResourceChange={setDestinationResource}
|
||||
showResources={true}
|
||||
placeholder={"Select destination(s)..."}
|
||||
saveGroupAssignments={useSave}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
}
|
||||
@@ -453,35 +461,36 @@ export function AccessControlModalContent({
|
||||
{!policy ? (
|
||||
<>
|
||||
{tab == "policy" && (
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("posture_checks")}
|
||||
disabled={!canContinueToPostureChecks}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab == "posture_checks" && (
|
||||
<Button variant={"secondary"} onClick={() => setTab("policy")}>
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "policy" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("posture_checks")}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tab == "posture_checks" && (
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("general")}
|
||||
disabled={continuePostureChecksDisabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("policy")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("general")}
|
||||
disabled={!canContinueToPostureChecks}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab == "general" && (
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, { useMemo } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
@@ -20,30 +18,13 @@ export default function AccessControlDestinationsCell({
|
||||
|
||||
if (firstRule?.destinationResource) {
|
||||
return (
|
||||
<AccessControlDestinationResourceCell
|
||||
resource={firstRule.destinationResource}
|
||||
/>
|
||||
<AccessControlResourceCell resource={firstRule.destinationResource} />
|
||||
);
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.destinations as Group[]} />
|
||||
) : null;
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
}
|
||||
|
||||
const AccessControlDestinationResourceCell = ({
|
||||
resource,
|
||||
}: {
|
||||
resource: PolicyRuleResource;
|
||||
}) => {
|
||||
const { data: resources, isLoading } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
if (isLoading) return <Skeleton height={35} width={"50%"} />;
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ResourceBadge resource={resources?.find((r) => r.id === resource.id)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import ResourceBadge from "@components/ui/ResourceBadge";
|
||||
import useFetchApi from "@utils/api";
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
resource?: PolicyRuleResource;
|
||||
};
|
||||
|
||||
export const AccessControlResourceCell = ({ resource }: Props) => {
|
||||
const { data: resources, isLoading: isLoadingResources } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
const { data: peers, isLoading: isLoadingPeers } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const isPeer = resource?.type === "peer";
|
||||
const peer = peers?.find((p) => p.id === resource?.id);
|
||||
|
||||
if ((isPeer && isLoadingPeers) || (!isPeer && isLoadingResources))
|
||||
return <Skeleton height={35} width={"50%"} />;
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<ResourceBadge
|
||||
resource={resources?.find((r) => r.id === resource?.id)}
|
||||
peer={peer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import MultipleGroups from "@components/ui/MultipleGroups";
|
||||
import React, { useMemo } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlResourceCell } from "@/modules/access-control/table/AccessControlResourceCell";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
@@ -12,7 +14,13 @@ export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
return undefined;
|
||||
}, [policy]);
|
||||
|
||||
if (firstRule?.sourceResource) {
|
||||
return <AccessControlResourceCell resource={firstRule.sourceResource} />;
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<MultipleGroups groups={firstRule.sources as Group[]} />
|
||||
) : null;
|
||||
) : (
|
||||
<EmptyRow />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import { DataTableRowsPerPage } from "@components/table/DataTableRowsPerPage";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import type { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { removeAllSpaces } from "@utils/helpers";
|
||||
import { ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { ClockFadingIcon, ExternalLinkIcon, PlusCircle } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -29,6 +29,7 @@ import AccessControlPortsCell from "@/modules/access-control/table/AccessControl
|
||||
import AccessControlPostureCheckCell from "@/modules/access-control/table/AccessControlPostureCheckCell";
|
||||
import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell";
|
||||
import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
|
||||
type Props = {
|
||||
policies?: Policy[];
|
||||
@@ -200,6 +201,41 @@ export default function AccessControlTable({
|
||||
const [currentRow, setCurrentRow] = useState<Policy>();
|
||||
const [currentCellClicked, setCurrentCellClicked] = useState("");
|
||||
|
||||
const [showTemporaryPolicies, setShowTemporaryPolicies] = useState(false);
|
||||
|
||||
const withTemporaryPolicies = useCallback(
|
||||
(condition: boolean) =>
|
||||
policies?.filter((policy) =>
|
||||
condition
|
||||
? policy?.name?.startsWith("Temporary") &&
|
||||
policy?.name?.endsWith("client") &&
|
||||
policy?.description?.startsWith("Temporary") &&
|
||||
policy?.description?.endsWith("client")
|
||||
: !(
|
||||
policy?.name?.startsWith("Temporary") &&
|
||||
policy?.name?.endsWith("client") &&
|
||||
policy?.description?.startsWith("Temporary") &&
|
||||
policy?.description?.endsWith("client")
|
||||
),
|
||||
) ?? [],
|
||||
[policies],
|
||||
);
|
||||
|
||||
const tempPolicies = useMemo(
|
||||
() => withTemporaryPolicies(true),
|
||||
[withTemporaryPolicies],
|
||||
);
|
||||
const regularPolicies = useMemo(
|
||||
() => withTemporaryPolicies(false),
|
||||
[withTemporaryPolicies],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showTemporaryPolicies && tempPolicies?.length === 0) {
|
||||
setShowTemporaryPolicies(false);
|
||||
}
|
||||
}, [showTemporaryPolicies, tempPolicies]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editModal && currentRow && (
|
||||
@@ -232,8 +268,9 @@ export default function AccessControlTable({
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
temporary: false,
|
||||
}}
|
||||
data={policies}
|
||||
data={showTemporaryPolicies ? tempPolicies : regularPolicies}
|
||||
onRowClick={(row, cell) => {
|
||||
setCurrentRow(row.original);
|
||||
setEditModal(true);
|
||||
@@ -301,66 +338,91 @@ export default function AccessControlTable({
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<ButtonGroup disabled={policies?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(undefined);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(true);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Active
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(false);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Inactive
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={policies?.length == 0}
|
||||
/>
|
||||
<DataTableRefreshButton
|
||||
isDisabled={policies?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/policies").then();
|
||||
mutate("/groups").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(table) => {
|
||||
return (
|
||||
<>
|
||||
<ButtonGroup disabled={policies?.length == 0}>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(undefined);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === undefined
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
All
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(true);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === true
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Active
|
||||
</ButtonGroup.Button>
|
||||
<ButtonGroup.Button
|
||||
onClick={() => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn("enabled")?.setFilterValue(false);
|
||||
}}
|
||||
disabled={policies?.length == 0}
|
||||
variant={
|
||||
table.getColumn("enabled")?.getFilterValue() === false
|
||||
? "tertiary"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
Inactive
|
||||
</ButtonGroup.Button>
|
||||
</ButtonGroup>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={policies?.length == 0}
|
||||
/>
|
||||
|
||||
{tempPolicies?.length > 0 && (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-sm text-xs"}>
|
||||
Show temporary policies created by the NetBird browser
|
||||
client. These policies are ephemeral and will be
|
||||
deleted automatically after a short period of time.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className={"h-[44px]"}
|
||||
variant={showTemporaryPolicies ? "tertiary" : "secondary"}
|
||||
onClick={() => {
|
||||
setShowTemporaryPolicies(!showTemporaryPolicies);
|
||||
}}
|
||||
>
|
||||
<ClockFadingIcon size={16} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
)}
|
||||
|
||||
<DataTableRefreshButton
|
||||
isDisabled={policies?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/policies").then();
|
||||
mutate("/groups").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</DataTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -365,6 +365,14 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "peer.user.add")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.name}</Value> <PeerConnectionInfo meta={m} /> was added
|
||||
with the NetBird IP <Value>{m.ip}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Group
|
||||
*/
|
||||
|
||||
@@ -37,12 +37,13 @@ export default function ActiveInactiveRow({
|
||||
<div className={"flex gap-2.5 items-start"}>
|
||||
<CircleIcon
|
||||
active={active}
|
||||
size={8}
|
||||
inactiveDot={inactiveDot}
|
||||
className={"mt-[0.34rem] shrink-0"}
|
||||
className={"mt-[0.45rem] shrink-0"}
|
||||
/>
|
||||
<div className={"flex flex-col min-w-0"}>
|
||||
<div
|
||||
className={"font-medium flex gap-2 items-center justify-center"}
|
||||
className={"font-medium flex gap-2 items-center justify-start"}
|
||||
>
|
||||
<TextWithTooltip text={text as string} maxChars={25} />
|
||||
{additionalInfo}
|
||||
|
||||
21
src/modules/peer/EphemeralPeerIndicator.tsx
Normal file
21
src/modules/peer/EphemeralPeerIndicator.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {PowerOffIcon} from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const EphemeralPeerIndicator = ({ peer }: Props) => {
|
||||
if (!peer.ephemeral) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltipContent = "This peer is an ephemeral peer. If it is disconnected for more than 10 minutes it will be removed.";
|
||||
|
||||
return (
|
||||
<FullTooltip content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}>
|
||||
<PowerOffIcon size={12} className={"shrink-0 text-yellow-400"} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
23
src/modules/peer/ExpirationDisabledIndicator.tsx
Normal file
23
src/modules/peer/ExpirationDisabledIndicator.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { TimerResetIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const ExpirationDisabledIndicator = ({ peer }: Props) => {
|
||||
if (peer.login_expiration_enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tooltipContent = "Expiration is disabled for this peer.";
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={<div className={"text-xs max-w-xs"}>{tooltipContent}</div>}
|
||||
>
|
||||
<TimerResetIcon size={14} className={"shrink-0 text-nb-gray-300"} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
27
src/modules/peer/LoginRequiredIndicator.tsx
Normal file
27
src/modules/peer/LoginRequiredIndicator.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
};
|
||||
export const LoginRequiredIndicator = ({ peer }: Props) => {
|
||||
if (!peer.login_expired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
{" "}
|
||||
This peer is offline and needs to be <br />
|
||||
re-authenticated because its login has expired.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AlertTriangle size={14} className={"shrink-0 text-red-500"} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
@@ -64,7 +64,7 @@ export default function PeerActionCell() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex justify-end pr-4"}>
|
||||
<div className={"flex justify-end pr-4 gap-3"}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function PeerAddressCell({ peer }: Props) {
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex gap-4 items-center min-w-[320px] max-w-[320px] group/cell transition-all hover:bg-nb-gray-800/10 py-2 px-3 rounded-md cursor-default"
|
||||
"flex gap-2.5 items-center min-w-[300px] max-w-[300px] group/cell transition-all hover:bg-nb-gray-800/10 py-2 px-3 rounded-md cursor-default"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -32,13 +32,13 @@ export default function PeerAddressCell({ peer }: Props) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full h-8 w-8 shrink-0 bg-nb-gray-920/80 transition-all",
|
||||
"flex items-center justify-center rounded-full h-3 w-3 shrink-0 relative -top-[0.5rem]",
|
||||
)}
|
||||
>
|
||||
{isEmpty(peer.country_code) ? (
|
||||
<GlobeIcon size={16} className={"text-nb-gray-300"} />
|
||||
) : (
|
||||
<RoundedFlag country={peer.country_code} size={20} />
|
||||
<RoundedFlag country={peer.country_code} size={12} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 font-light truncate">
|
||||
|
||||
80
src/modules/peers/PeerConnectButton.tsx
Normal file
80
src/modules/peers/PeerConnectButton.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { IconChevronDown } from "@tabler/icons-react";
|
||||
import * as React from "react";
|
||||
import { usePeer } from "@/contexts/PeerProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
|
||||
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
export const PeerConnectButton = () => {
|
||||
const { peer } = usePeer();
|
||||
const isConnected = peer.connected;
|
||||
const os = getOperatingSystem(peer?.os);
|
||||
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
|
||||
|
||||
if (isMobile) return;
|
||||
|
||||
return isConnected ? (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
asChild={true}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className={"group"}>
|
||||
<ConnectButton />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-auto"
|
||||
align="start"
|
||||
side={"bottom"}
|
||||
sideOffset={8}
|
||||
>
|
||||
<SSHButton peer={peer} isDropdown={true} />
|
||||
<RDPButton peer={peer} isDropdown={true} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-[200px] text-xs"}>
|
||||
Connecting via SSH or RDP is only available when the peer is online.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ConnectButton disabled={true} />
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectButton = ({ disabled }: { disabled?: boolean }) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex gap-2 items-center text-sm text-nb-gray-300 hover:text-white disabled:cursor-not-allowed enabled:cursor-pointer enabled:hover:bg-nb-gray-800/60 rounded-md py-2 px-3 disabled:text-nb-gray-700",
|
||||
// group data state open
|
||||
"group-data-[state=open]:bg-nb-gray-800/30",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
<IconChevronDown size={14} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,9 @@ import { useLoggedInUser, useUsers } from "@/contexts/UsersProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import ActiveInactiveRow from "@/modules/common-table-rows/ActiveInactiveRow";
|
||||
import { ExitNodePeerIndicator } from "@/modules/exit-node/ExitNodePeerIndicator";
|
||||
import { EphemeralPeerIndicator } from "@/modules/peer/EphemeralPeerIndicator";
|
||||
import { ExpirationDisabledIndicator } from "@/modules/peer/ExpirationDisabledIndicator";
|
||||
import { LoginRequiredIndicator } from "@/modules/peer/LoginRequiredIndicator";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
@@ -27,7 +30,7 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center max-w-[300px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
|
||||
"flex items-center max-w-[280px] gap-2 dark:text-neutral-300 text-neutral-500 transition-all py-2 px-3 rounded-md ",
|
||||
linkToPeer &&
|
||||
"hover:text-neutral-100 hover:bg-nb-gray-800/60 cursor-pointer",
|
||||
)}
|
||||
@@ -39,7 +42,14 @@ export default function PeerNameCell({ peer, linkToPeer = true }: Props) {
|
||||
active={peer.connected}
|
||||
text={peer.name}
|
||||
additionalInfo={
|
||||
isOwnerOrAdmin && <ExitNodePeerIndicator peer={peer} />
|
||||
isOwnerOrAdmin && (
|
||||
<>
|
||||
<ExitNodePeerIndicator peer={peer} />
|
||||
<EphemeralPeerIndicator peer={peer} />
|
||||
<ExpirationDisabledIndicator peer={peer} />
|
||||
<LoginRequiredIndicator peer={peer} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={"text-nb-gray-400 font-light truncate"}>
|
||||
|
||||
28
src/modules/peers/PeerOperatingSystemIcon.tsx
Normal file
28
src/modules/peers/PeerOperatingSystemIcon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { OSLogo } from "@/modules/peers/PeerOSCell";
|
||||
|
||||
type Props = {
|
||||
os: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const PeerOperatingSystemIcon = ({ os, className }: Props) => {
|
||||
const operatingSystem = getOperatingSystem(os);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
|
||||
"w-4 h-4 shrink-0",
|
||||
operatingSystem === OperatingSystem.WINDOWS && "p-[2.5px]",
|
||||
operatingSystem === OperatingSystem.APPLE && "p-[2.7px]",
|
||||
operatingSystem === OperatingSystem.FREEBSD && "p-[1.5px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<OSLogo os={os} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,8 +2,7 @@ import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { notify } from "@components/Notification";
|
||||
import LoginExpiredBadge from "@components/ui/LoginExpiredBadge";
|
||||
import { HelpCircle, TimerResetIcon } from "lucide-react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
@@ -49,46 +48,39 @@ export default function PeerStatusCell({ peer }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
return needsApproval ? (
|
||||
<div className={"flex gap-3 items-center text-xs"}>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-xs text-xs"}>
|
||||
The peer needs to be approved by an administrator before it can
|
||||
connect to other peers.
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
>
|
||||
<Badge variant={"netbird"} className={"px-3 font-medium"}>
|
||||
<HelpCircle size={12} />
|
||||
Approval required
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
className={"h-[32px]"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!canApprove) return;
|
||||
approvePeer();
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={"flex gap-3 items-center text-xs"}>
|
||||
{!peer.login_expiration_enabled && (
|
||||
<Badge variant={"gray"} className={"px-2"}>
|
||||
<TimerResetIcon size={13} className={"relative -top-[1px]"} />
|
||||
Expiration disabled
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<LoginExpiredBadge loginExpired={peer.login_expired} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
needsApproval && (
|
||||
<div className={"flex gap-3 items-center text-xs"}>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-xs text-xs"}>
|
||||
The peer needs to be approved by an administrator before it can
|
||||
connect to other peers.
|
||||
</div>
|
||||
}
|
||||
interactive={false}
|
||||
>
|
||||
<Badge variant={"netbird"} className={"px-3 font-medium"}>
|
||||
<HelpCircle size={12} />
|
||||
Approval required
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
{ canApprove && (
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs"}
|
||||
className={"h-[32px]"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!canApprove) return;
|
||||
approvePeer();
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,15 @@ import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useApplicationContext } from "@/contexts/ApplicationProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
|
||||
type Props = {
|
||||
version: string;
|
||||
os: string;
|
||||
serial?: string;
|
||||
};
|
||||
export default function PeerVersionCell({ version, os }: Props) {
|
||||
export default function PeerVersionCell({ version, os, serial }: Props) {
|
||||
const { latestVersion, latestUrl } = useApplicationContext();
|
||||
|
||||
const updateAvailable = useMemo(() => {
|
||||
@@ -35,56 +38,83 @@ export default function PeerVersionCell({ version, os }: Props) {
|
||||
return <ArrowUpCircleIcon size={15} className={"text-netbird"} />;
|
||||
}, []);
|
||||
|
||||
return updateAvailable ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger>
|
||||
<div className="flex gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all hover:bg-nb-gray-800/60 py-2 px-3 rounded-md items-center">
|
||||
<MemoizedNetBirdIcon />
|
||||
{version == "development" ? "dev" : version}
|
||||
<div className={"relative"}>
|
||||
<span className="animate-ping absolute left-0 inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20"></span>
|
||||
{updateIcon}
|
||||
return (
|
||||
<div className={"flex flex-col gap-1"}>
|
||||
{updateAvailable ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={10}>
|
||||
<TooltipTrigger>
|
||||
<div className="flex gap-2 dark:text-neutral-300 text-neutral-500 hover:text-neutral-100 transition-all rounded-md items-center">
|
||||
<MemoizedNetBirdIcon />
|
||||
{version == "development" ? "dev" : version}
|
||||
<div className={"relative"}>
|
||||
<span className="animate-ping absolute left-0 inline-flex h-[15px] w-[15px] rounded-full bg-netbird opacity-20"></span>
|
||||
{updateIcon}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<div
|
||||
className={
|
||||
" inline-flex gap-2 items-center rounded-md text-xs my-2"
|
||||
}
|
||||
>
|
||||
<MemoizedNetBirdIcon />
|
||||
<span>{version}</span>
|
||||
<ArrowRightIcon size={16} className={"text-netbird"} />
|
||||
<span className={"text-netbird"}>{latestVersion}</span>
|
||||
</div>
|
||||
<p className={"font-medium"}>Update available </p>
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-300 flex flex-col gap-1 max-w-[300px] text-xs mt-1"
|
||||
}
|
||||
>
|
||||
A new version of Netbird is available. Please update your client
|
||||
to get the latest features and bug fixes.
|
||||
</div>
|
||||
<InlineLink
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
href={latestUrl as string}
|
||||
target={"_blank"}
|
||||
className={"mt-2 mb-2 text-xs"}
|
||||
>
|
||||
Download & Changelog
|
||||
</InlineLink>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<div className="inline-flex gap-2 dark:text-neutral-300 text-neutral-500 items-center">
|
||||
<MemoizedNetBirdIcon />
|
||||
{version == "development" ? "dev" : version}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{os && os !== "" && os !== " " && (
|
||||
<FullTooltip
|
||||
delayDuration={500}
|
||||
disabled={!serial || serial === ""}
|
||||
content={
|
||||
<div className={"text-xs"}>
|
||||
<span className={"text-nb-gray-100 font-medium"}>Serial: </span>
|
||||
{serial}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
" inline-flex gap-2 items-center rounded-md text-xs my-2"
|
||||
"flex items-center gap-2 text-neutral-300 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<MemoizedNetBirdIcon />
|
||||
<span>{version}</span>
|
||||
<ArrowRightIcon size={16} className={"text-netbird"} />
|
||||
<span className={"text-netbird"}>{latestVersion}</span>
|
||||
</div>
|
||||
<p className={"font-medium"}>Update available </p>
|
||||
<PeerOperatingSystemIcon os={os} />
|
||||
|
||||
<div
|
||||
className={
|
||||
"text-neutral-300 flex flex-col gap-1 max-w-[300px] text-xs mt-1"
|
||||
}
|
||||
>
|
||||
A new version of Netbird is available. Please update your client to
|
||||
get the latest features and bug fixes.
|
||||
{os}
|
||||
</div>
|
||||
<InlineLink
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
href={latestUrl as string}
|
||||
target={"_blank"}
|
||||
className={"mt-2 mb-2 text-xs"}
|
||||
>
|
||||
Download & Changelog
|
||||
</InlineLink>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<div className="inline-flex gap-2 dark:text-neutral-300 text-neutral-500 py-2 px-3 items-center">
|
||||
<MemoizedNetBirdIcon />
|
||||
{version == "development" ? "dev" : version}
|
||||
</FullTooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { uniqBy } from "lodash";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeerProvider from "@/contexts/PeerProvider";
|
||||
@@ -37,6 +37,9 @@ import PeerNameCell from "@/modules/peers/PeerNameCell";
|
||||
import { PeerOSCell } from "@/modules/peers/PeerOSCell";
|
||||
import PeerStatusCell from "@/modules/peers/PeerStatusCell";
|
||||
import PeerVersionCell from "@/modules/peers/PeerVersionCell";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { MonitorDotIcon } from "lucide-react";
|
||||
import { PeerConnectButton } from "@/modules/peers/PeerConnectButton";
|
||||
|
||||
const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
{
|
||||
@@ -72,6 +75,16 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <PeerNameCell peer={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "connect",
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
cell: ({ row }) => (
|
||||
<PeerProvider peer={row.original}>
|
||||
<PeerConnectButton />
|
||||
</PeerProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "approval_required",
|
||||
accessorKey: "approval_required",
|
||||
@@ -157,7 +170,11 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
return <DataTableHeader column={column}>Version</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<PeerVersionCell version={row.original.version} os={row.original.os} />
|
||||
<PeerVersionCell
|
||||
version={row.original.version}
|
||||
os={row.original.os}
|
||||
serial={row.original.serial_number}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -243,6 +260,32 @@ export default function PeersTable({
|
||||
}
|
||||
};
|
||||
|
||||
const [showBrowserPeers, setShowBrowserPeers] = useState(false);
|
||||
|
||||
const withBrowserPeers = useCallback(
|
||||
(condition: boolean) =>
|
||||
peers?.filter((peer) =>
|
||||
condition
|
||||
? peer.kernel_version === "wasm"
|
||||
: peer.kernel_version !== "wasm",
|
||||
) ?? [],
|
||||
[peers],
|
||||
);
|
||||
|
||||
const browserPeers = useMemo(() => {
|
||||
return withBrowserPeers(true);
|
||||
}, [withBrowserPeers]);
|
||||
|
||||
const regularPeers = useMemo(() => {
|
||||
return withBrowserPeers(false);
|
||||
}, [withBrowserPeers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showBrowserPeers && browserPeers?.length === 0) {
|
||||
setShowBrowserPeers(false);
|
||||
}
|
||||
}, [showBrowserPeers, browserPeers]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PeerMultiSelect
|
||||
@@ -258,7 +301,7 @@ export default function PeersTable({
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={PeersTableColumns}
|
||||
data={peers}
|
||||
data={showBrowserPeers ? browserPeers : regularPeers}
|
||||
searchPlaceholder={"Search by name, IP, owner or group..."}
|
||||
columnVisibility={{
|
||||
select: permission.groups.read,
|
||||
@@ -271,7 +314,9 @@ export default function PeersTable({
|
||||
user_name: false,
|
||||
user_email: false,
|
||||
actions: permission.peers.update,
|
||||
connect: permission.peers.update,
|
||||
groups: permission.groups.read,
|
||||
os: false,
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
getStartedCard={
|
||||
@@ -471,6 +516,28 @@ export default function PeersTable({
|
||||
/>
|
||||
)}
|
||||
|
||||
{browserPeers?.length > 0 && (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"max-w-sm text-xs"}>
|
||||
Show temporary peers created by the NetBird browser client.
|
||||
These peers are ephemeral and will be deleted automatically
|
||||
after a short period of time.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className={"h-[44px]"}
|
||||
variant={showBrowserPeers ? "tertiary" : "secondary"}
|
||||
onClick={() => {
|
||||
setShowBrowserPeers(!showBrowserPeers);
|
||||
}}
|
||||
>
|
||||
<MonitorDotIcon size={16} />
|
||||
</Button>
|
||||
</FullTooltip>
|
||||
)}
|
||||
|
||||
<DataTableRefreshButton
|
||||
isDisabled={peers?.length == 0}
|
||||
onClick={() => {
|
||||
|
||||
72
src/modules/remote-access/rdp/RDPButton.tsx
Normal file
72
src/modules/remote-access/rdp/RDPButton.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import Button from "@components/Button";
|
||||
import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { CircleHelpIcon, MonitorIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip";
|
||||
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
isDropdown?: boolean;
|
||||
};
|
||||
|
||||
export const RDPButton = ({ peer, isDropdown = false }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const disabled = !peer.connected || !permission.peers.update;
|
||||
const hasPermission = permission.peers.update;
|
||||
|
||||
const isWindows = getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS;
|
||||
|
||||
const openRDPPage = () => {
|
||||
window.open(
|
||||
`peer/rdp?id=${peer.id}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=1200,height=650,left=100,top=100,location=no,toolbar=no,menubar=no,status=no",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
isWindows && (
|
||||
<>
|
||||
<div>
|
||||
<RDPTooltip
|
||||
disabled={!disabled}
|
||||
hasPermission={hasPermission}
|
||||
side={isDropdown ? "left" : "top"}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<DropdownMenuItem
|
||||
onClick={openRDPPage}
|
||||
disabled={disabled}
|
||||
className={"w-full"}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<MonitorIcon size={14} className={"shrink-0"} />
|
||||
RDP
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={openRDPPage}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MonitorIcon size={16} />
|
||||
RDP
|
||||
{disabled && <CircleHelpIcon size={12} />}
|
||||
</Button>
|
||||
)}
|
||||
</RDPTooltip>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
170
src/modules/remote-access/rdp/RDPCertificateModal.tsx
Normal file
170
src/modules/remote-access/rdp/RDPCertificateModal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import Separator from "@components/Separator";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CertificateInfo,
|
||||
CertificatePromptInfo,
|
||||
} from "./useRDPCertificateHandler";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
certificateInfo: CertificatePromptInfo | null;
|
||||
onAccept: (remember: boolean) => void;
|
||||
onReject: () => void;
|
||||
};
|
||||
|
||||
export const RDPCertificateModal = ({
|
||||
open,
|
||||
certificateInfo,
|
||||
onAccept,
|
||||
onReject,
|
||||
}: Props) => {
|
||||
const [rememberCertificate, setRememberCertificate] = useState(false);
|
||||
if (!certificateInfo) return null;
|
||||
const { hostname, certificate, isChange } = certificateInfo;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={undefined}>
|
||||
<ModalContent maxWidthClass={"max-w-2xl"} showClose={false}>
|
||||
<ModalHeader
|
||||
icon={<LockIcon className={"text-netbird"} size={18} />}
|
||||
title={"RDP Certificate"}
|
||||
description={hostname}
|
||||
color={"netbird"}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-6"}>
|
||||
{isChange && (
|
||||
<Callout variant={"warning"}>
|
||||
Warning! Certificate has changed. Only proceed if you trust this
|
||||
connection.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>Certificate Details</Label>
|
||||
<HelpText>
|
||||
Certificated could not be verified by a trusted authority. Review
|
||||
the certificate information before proceeding with the connection.
|
||||
</HelpText>
|
||||
<CertificateDetailsList certificate={certificate} />
|
||||
</div>
|
||||
|
||||
<label className={"flex items-center space-x-3 cursor-pointer"}>
|
||||
<Checkbox
|
||||
id="remember-cert"
|
||||
checked={rememberCertificate}
|
||||
variant={"tableCell"}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberCertificate(checked === true)
|
||||
}
|
||||
/>
|
||||
<div className={"font-normal text-sm text-nb-gray-200"}>
|
||||
Always trust{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
{'"' + certificate?.issuer?.replace("CN=", "") + '"'}
|
||||
</span>{" "}
|
||||
when connecting to{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
{'"' + hostname + '"'}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<Button variant={"secondary"} onClick={onReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => onAccept(rememberCertificate)}
|
||||
>
|
||||
Accept & Continue
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const CertificateDetailsList = ({
|
||||
certificate,
|
||||
}: {
|
||||
certificate: CertificateInfo;
|
||||
}) => {
|
||||
if (!certificate) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-930 border border-nb-gray-900 rounded-md mt-3 flex flex-col py-3 px-4 gap-2"
|
||||
}
|
||||
>
|
||||
<CertificateDetailsListItem
|
||||
label={"Issuer"}
|
||||
value={certificate.issuer || "N/A"}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Subject"}
|
||||
value={certificate.subject || "N/A"}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Valid From"}
|
||||
value={
|
||||
certificate.validFrom
|
||||
? new Date(certificate.validFrom).toLocaleString()
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Valid To"}
|
||||
value={
|
||||
certificate.validTo
|
||||
? new Date(certificate.validTo).toLocaleString()
|
||||
: "N/A"
|
||||
}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Key Size"}
|
||||
value={certificate.keySize ? `${certificate.keySize} bits` : "N/A"}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Serial Number"}
|
||||
value={certificate.serialNumber || "N/A"}
|
||||
/>
|
||||
<CertificateDetailsListItem
|
||||
label={"Fingerprint"}
|
||||
value={certificate.fingerprint || "N/A"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CertificateDetailsListItem = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) => {
|
||||
return (
|
||||
<div key={label} className={"flex justify-between text-xs gap-10"}>
|
||||
<span className={"font-mono text-nb-gray-200 w-[200px]"}>{label}:</span>
|
||||
<span className={"font-mono text-nb-gray-300 break-all text-left w-full"}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
200
src/modules/remote-access/rdp/RDPCredentialsModal.tsx
Normal file
200
src/modules/remote-access/rdp/RDPCredentialsModal.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import * as React from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
ChevronsLeftRightEllipsis,
|
||||
ExternalLinkIcon,
|
||||
KeyRoundIcon,
|
||||
MonitorIcon,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import Separator from "@components/Separator";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Button from "@components/Button";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import {
|
||||
RDP_DOCS_LINK,
|
||||
RDPCredentials,
|
||||
} from "@/modules/remote-access/rdp/useRemoteDesktop";
|
||||
import { IconLoader2 } from "@tabler/icons-react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
peer: Peer;
|
||||
onConnect?: (credentials: RDPCredentials) => void;
|
||||
error?: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const RDPCredentialsModal = ({
|
||||
open,
|
||||
peer,
|
||||
onConnect,
|
||||
error,
|
||||
loading,
|
||||
}: Props) => {
|
||||
const [username, setUsername] = useState("Administrator");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [port, setPort] = useState("3389");
|
||||
|
||||
const userNameError = useMemo(() => {
|
||||
if (username?.length === 0) return "Username cannot be empty";
|
||||
}, [username]);
|
||||
|
||||
const portError = useMemo(() => {
|
||||
const portNumber = Number(port);
|
||||
const isValid =
|
||||
Number.isInteger(portNumber) && portNumber > 0 && portNumber <= 65535;
|
||||
if (!isValid) return "Port must be a number between 1 and 65535";
|
||||
}, [port]);
|
||||
|
||||
const hasAnyError = useMemo(() => {
|
||||
if (userNameError !== undefined) return true;
|
||||
return portError !== undefined;
|
||||
}, [userNameError, portError]);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
if (hasAnyError || !onConnect) return;
|
||||
onConnect({
|
||||
username,
|
||||
password,
|
||||
port: Number(port),
|
||||
});
|
||||
}, [hasAnyError, onConnect, username, password, port]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !hasAnyError && !loading) {
|
||||
handleConnect();
|
||||
}
|
||||
},
|
||||
[handleConnect, hasAnyError, loading],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={undefined}>
|
||||
<ModalContent maxWidthClass={"max-w-xl"} showClose={false}>
|
||||
<ModalHeader
|
||||
icon={<MonitorIcon className={"text-netbird"} size={18} />}
|
||||
title={peer.name}
|
||||
description={`Connect to ${peer.ip} via RDP`}
|
||||
color={"netbird"}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<form
|
||||
className={"px-8 py-6 flex flex-col gap-8"}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className={"bg-red-50 border border-red-200 rounded-md p-4"}>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 text-red-800 font-medium mb-1"
|
||||
}
|
||||
>
|
||||
Error
|
||||
</div>
|
||||
<p className={"text-sm text-red-700"}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>Username & Password</Label>
|
||||
<HelpText>
|
||||
Enter the credentials required to authenticate with the remote
|
||||
host.
|
||||
</HelpText>
|
||||
<div className={"flex flex-col gap-2 w-full"}>
|
||||
<Input
|
||||
placeholder={"Administrator"}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
name="username"
|
||||
autoComplete={"username"}
|
||||
error={userNameError}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
customPrefix={
|
||||
<User2 size={16} className={"text-nb-gray-300"} />
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
value={password}
|
||||
placeholder={"Enter password"}
|
||||
type={"password"}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
name="password"
|
||||
autoComplete={"current-password"}
|
||||
error={undefined}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
customPrefix={
|
||||
<KeyRoundIcon size={16} className={"text-nb-gray-300"} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Port</Label>
|
||||
<HelpText>
|
||||
Specify the RDP port for your remote connection.
|
||||
</HelpText>
|
||||
<Input
|
||||
maxWidthClass={""}
|
||||
placeholder={"3389"}
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
type={"number"}
|
||||
error={portError}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
customPrefix={
|
||||
<ChevronsLeftRightEllipsis
|
||||
size={16}
|
||||
className={"text-nb-gray-300"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={RDP_DOCS_LINK} target={"_blank"}>
|
||||
RDP
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary"}
|
||||
disabled={hasAnyError || loading}
|
||||
onClick={handleConnect}
|
||||
>
|
||||
{loading && <IconLoader2 size={16} className={"animate-spin"} />}
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
37
src/modules/remote-access/rdp/RDPTooltip.tsx
Normal file
37
src/modules/remote-access/rdp/RDPTooltip.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import * as React from "react";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
hasPermission?: boolean;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
};
|
||||
export const RDPTooltip = ({
|
||||
disabled,
|
||||
children,
|
||||
hasPermission,
|
||||
side = "top",
|
||||
}: Props) => {
|
||||
return (
|
||||
<FullTooltip
|
||||
className={"w-full"}
|
||||
side={side}
|
||||
content={
|
||||
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
|
||||
{hasPermission ? (
|
||||
<div>This peer is offline and cannot be accessed via RDP.</div>
|
||||
) : (
|
||||
<div>
|
||||
You do not have permission to launch an RDP session. Please
|
||||
contact your administrator.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
809
src/modules/remote-access/rdp/ironrdp-input-handler.ts
Normal file
809
src/modules/remote-access/rdp/ironrdp-input-handler.ts
Normal file
@@ -0,0 +1,809 @@
|
||||
/**
|
||||
* IronRDP Input Handler for NetBird WASM Client
|
||||
* Handles mouse and keyboard input for IronRDP sessions
|
||||
*/
|
||||
import type { IronRDPModule, RDPSession } from "./ironrdp-wasm-bridge";
|
||||
|
||||
interface DeviceEvent {
|
||||
free?(): void;
|
||||
}
|
||||
interface InputTransaction {
|
||||
addEvent(event: DeviceEvent): void;
|
||||
free?(): void;
|
||||
}
|
||||
interface IronRDPAPI extends IronRDPModule {
|
||||
DeviceEvent: {
|
||||
mouseButtonPressed(button: number): DeviceEvent;
|
||||
mouseButtonReleased(button: number): DeviceEvent;
|
||||
mouseMove(x: number, y: number): DeviceEvent;
|
||||
wheelRotations(isVertical: boolean, rotationUnits: number): DeviceEvent;
|
||||
keyPressed(scancode: number): DeviceEvent;
|
||||
keyReleased(scancode: number): DeviceEvent;
|
||||
unicode(code: number): DeviceEvent;
|
||||
};
|
||||
InputTransaction: new () => InputTransaction;
|
||||
}
|
||||
interface ExtendedRDPSession extends RDPSession {
|
||||
applyInputs(transaction: InputTransaction): void;
|
||||
}
|
||||
interface CoordinateResult {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
toggleFullscreen?: () => void;
|
||||
}
|
||||
}
|
||||
export class IronRDPInputHandler {
|
||||
private ironrdp: IronRDPAPI;
|
||||
private session: ExtendedRDPSession;
|
||||
private canvas: HTMLCanvasElement;
|
||||
private isActive = false;
|
||||
private mouseButtonStates: Record<number, boolean> = {
|
||||
0: false,
|
||||
1: false,
|
||||
2: false,
|
||||
};
|
||||
private keyStates = new Map<string, boolean>();
|
||||
private currentMouseX = 0;
|
||||
private currentMouseY = 0;
|
||||
|
||||
// Bound event handlers for proper cleanup
|
||||
private boundHandlers = {
|
||||
mouseDown: this.handleMouseDown.bind(this),
|
||||
mouseUp: this.handleMouseUp.bind(this),
|
||||
mouseMove: this.handleMouseMove.bind(this),
|
||||
mouseEnter: this.handleMouseEnter.bind(this),
|
||||
wheel: this.handleWheel.bind(this),
|
||||
touchStart: this.handleTouchStart.bind(this),
|
||||
touchMove: this.handleTouchMove.bind(this),
|
||||
touchEnd: this.handleTouchEnd.bind(this),
|
||||
keyDown: this.handleKeyDown.bind(this),
|
||||
keyUp: this.handleKeyUp.bind(this),
|
||||
paste: this.handlePaste.bind(this),
|
||||
copy: this.handleCopy.bind(this),
|
||||
contextMenu: (e: Event) => e.preventDefault(),
|
||||
focus: this.handleFocus.bind(this),
|
||||
blur: this.handleBlur.bind(this),
|
||||
click: this.handleClick.bind(this),
|
||||
globalKeyDown: this.handleGlobalKeyDown.bind(this),
|
||||
};
|
||||
// Keyboard code to scancode mappings (using event.code instead of deprecated keyCode)
|
||||
private readonly codeToScancode: Record<string, number> = {
|
||||
// Letters
|
||||
KeyA: 0x1e,
|
||||
KeyB: 0x30,
|
||||
KeyC: 0x2e,
|
||||
KeyD: 0x20,
|
||||
KeyE: 0x12,
|
||||
KeyF: 0x21,
|
||||
KeyG: 0x22,
|
||||
KeyH: 0x23,
|
||||
KeyI: 0x17,
|
||||
KeyJ: 0x24,
|
||||
KeyK: 0x25,
|
||||
KeyL: 0x26,
|
||||
KeyM: 0x32,
|
||||
KeyN: 0x31,
|
||||
KeyO: 0x18,
|
||||
KeyP: 0x19,
|
||||
KeyQ: 0x10,
|
||||
KeyR: 0x13,
|
||||
KeyS: 0x1f,
|
||||
KeyT: 0x14,
|
||||
KeyU: 0x16,
|
||||
KeyV: 0x2f,
|
||||
KeyW: 0x11,
|
||||
KeyX: 0x2d,
|
||||
KeyY: 0x15,
|
||||
KeyZ: 0x2c,
|
||||
// Numbers
|
||||
Digit0: 0x0b,
|
||||
Digit1: 0x02,
|
||||
Digit2: 0x03,
|
||||
Digit3: 0x04,
|
||||
Digit4: 0x05,
|
||||
Digit5: 0x06,
|
||||
Digit6: 0x07,
|
||||
Digit7: 0x08,
|
||||
Digit8: 0x09,
|
||||
Digit9: 0x0a,
|
||||
// Function keys
|
||||
F1: 0x3b,
|
||||
F2: 0x3c,
|
||||
F3: 0x3d,
|
||||
F4: 0x3e,
|
||||
F5: 0x3f,
|
||||
F6: 0x40,
|
||||
F7: 0x41,
|
||||
F8: 0x42,
|
||||
F9: 0x43,
|
||||
F10: 0x44,
|
||||
F11: 0x57,
|
||||
F12: 0x58,
|
||||
// Special keys
|
||||
Backspace: 0x0e,
|
||||
Tab: 0x0f,
|
||||
Enter: 0x1c,
|
||||
ShiftLeft: 0x2a,
|
||||
ShiftRight: 0x36,
|
||||
ControlLeft: 0x1d,
|
||||
ControlRight: 0x9d,
|
||||
AltLeft: 0x38,
|
||||
AltRight: 0xb8,
|
||||
CapsLock: 0x3a,
|
||||
Escape: 0x01,
|
||||
Space: 0x39,
|
||||
PageUp: 0xe049,
|
||||
PageDown: 0xe051,
|
||||
End: 0xe04f,
|
||||
Home: 0xe047,
|
||||
ArrowLeft: 0xe04b,
|
||||
ArrowUp: 0xe048,
|
||||
ArrowRight: 0xe04d,
|
||||
ArrowDown: 0xe050,
|
||||
Insert: 0xe052,
|
||||
Delete: 0xe053,
|
||||
MetaLeft: this.isMacOS() ? 0x1d : 0x5b,
|
||||
MetaRight: this.isMacOS() ? 0x9d : 0x5c,
|
||||
// Punctuation
|
||||
Semicolon: 0x27,
|
||||
Equal: 0x0d,
|
||||
Comma: 0x33,
|
||||
Minus: 0x0c,
|
||||
Period: 0x34,
|
||||
Slash: 0x35,
|
||||
Backquote: 0x29,
|
||||
BracketLeft: 0x1a,
|
||||
Backslash: 0x2b,
|
||||
BracketRight: 0x1b,
|
||||
Quote: 0x28,
|
||||
// Numpad keys
|
||||
Numpad0: 0x52,
|
||||
Numpad1: 0x4f,
|
||||
Numpad2: 0x50,
|
||||
Numpad3: 0x51,
|
||||
Numpad4: 0x4b,
|
||||
Numpad5: 0x4c,
|
||||
Numpad6: 0x4d,
|
||||
Numpad7: 0x47,
|
||||
Numpad8: 0x48,
|
||||
Numpad9: 0x49,
|
||||
NumpadDecimal: 0x53,
|
||||
NumpadDivide: 0xe035,
|
||||
NumpadMultiply: 0x37,
|
||||
NumpadSubtract: 0x4a,
|
||||
NumpadAdd: 0x4e,
|
||||
NumpadEnter: 0xe01c,
|
||||
NumLock: 0x45,
|
||||
// System keys
|
||||
PrintScreen: 0xe037,
|
||||
ScrollLock: 0x46,
|
||||
Pause: 0xe11d,
|
||||
// Additional Windows/Context keys
|
||||
ContextMenu: 0xe05d,
|
||||
// Additional function keys (F13-F24)
|
||||
F13: 0x64,
|
||||
F14: 0x65,
|
||||
F15: 0x66,
|
||||
F16: 0x67,
|
||||
F17: 0x68,
|
||||
F18: 0x69,
|
||||
F19: 0x6a,
|
||||
F20: 0x6b,
|
||||
F21: 0x6c,
|
||||
F22: 0x6d,
|
||||
F23: 0x6e,
|
||||
F24: 0x76,
|
||||
// Media keys
|
||||
AudioVolumeDown: 0xe02e,
|
||||
AudioVolumeUp: 0xe030,
|
||||
AudioVolumeMute: 0xe020,
|
||||
MediaPlayPause: 0xe022,
|
||||
MediaStop: 0xe024,
|
||||
MediaTrackPrevious: 0xe010,
|
||||
MediaTrackNext: 0xe019,
|
||||
// Browser/Application keys
|
||||
BrowserBack: 0xe06a,
|
||||
BrowserForward: 0xe069,
|
||||
BrowserRefresh: 0xe067,
|
||||
BrowserStop: 0xe068,
|
||||
BrowserSearch: 0xe065,
|
||||
BrowserFavorites: 0xe066,
|
||||
BrowserHome: 0xe032,
|
||||
LaunchMail: 0xe06c,
|
||||
LaunchApp1: 0xe06b,
|
||||
LaunchApp2: 0xe021,
|
||||
// International keys
|
||||
IntlBackslash: 0x56,
|
||||
IntlRo: 0x73,
|
||||
IntlYen: 0x7d,
|
||||
};
|
||||
private readonly mouseButtonMap: Record<number, number> = {
|
||||
0: 0,
|
||||
1: 1,
|
||||
2: 2,
|
||||
};
|
||||
private touchState = {
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
touching: false,
|
||||
};
|
||||
constructor(
|
||||
ironrdp: IronRDPModule,
|
||||
session: RDPSession,
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
this.ironrdp = ironrdp as IronRDPAPI;
|
||||
this.session = session as ExtendedRDPSession;
|
||||
this.canvas = canvas;
|
||||
this.setupEventListeners();
|
||||
// Initialize mouse position to unknown - will be set on first mouse event
|
||||
this.currentMouseX = -1;
|
||||
this.currentMouseY = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the current platform is macOS
|
||||
*/
|
||||
private isMacOS(): boolean {
|
||||
if ("userAgentData" in navigator && (navigator as any).userAgentData) {
|
||||
return (navigator as any).userAgentData.platform === "macOS";
|
||||
}
|
||||
// Fallback
|
||||
return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
/**
|
||||
* Calculate canvas coordinates with letterbox correction for fullscreen mode
|
||||
*/
|
||||
private getCanvasCoordinates(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
): CoordinateResult {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
// Calculate the actual rendered size of the canvas content
|
||||
const canvasAspectRatio = this.canvas.width / this.canvas.height;
|
||||
const containerAspectRatio = rect.width / rect.height;
|
||||
let renderWidth: number,
|
||||
renderHeight: number,
|
||||
offsetX: number,
|
||||
offsetY: number;
|
||||
// Check if we're using object-fit: contain (letterboxing)
|
||||
const isFullscreen =
|
||||
document.fullscreenElement === this.canvas ||
|
||||
document.fullscreenElement === this.canvas.parentElement;
|
||||
const hasLetterbox = isFullscreen && this.canvas.style.objectFit !== "fill";
|
||||
if (hasLetterbox && canvasAspectRatio !== containerAspectRatio) {
|
||||
// Calculate actual rendered dimensions with letterboxing
|
||||
if (canvasAspectRatio > containerAspectRatio) {
|
||||
// Canvas is wider - letterbox on top/bottom
|
||||
renderWidth = rect.width;
|
||||
renderHeight = rect.width / canvasAspectRatio;
|
||||
offsetX = 0;
|
||||
offsetY = (rect.height - renderHeight) / 2;
|
||||
} else {
|
||||
// Canvas is taller - letterbox on left/right
|
||||
renderWidth = rect.height * canvasAspectRatio;
|
||||
renderHeight = rect.height;
|
||||
offsetX = (rect.width - renderWidth) / 2;
|
||||
offsetY = 0;
|
||||
}
|
||||
} else {
|
||||
// No letterboxing - canvas fills the entire rect
|
||||
renderWidth = rect.width;
|
||||
renderHeight = rect.height;
|
||||
offsetX = 0;
|
||||
offsetY = 0;
|
||||
}
|
||||
// Calculate scale factors based on actual render size
|
||||
const scaleX = this.canvas.width / renderWidth;
|
||||
const scaleY = this.canvas.height / renderHeight;
|
||||
// Adjust coordinates for letterbox offset
|
||||
const relativeX = clientX - rect.left - offsetX;
|
||||
const relativeY = clientY - rect.top - offsetY;
|
||||
// Clamp to valid canvas area
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(this.canvas.width - 1, Math.round(relativeX * scaleX)),
|
||||
);
|
||||
const y = Math.max(
|
||||
0,
|
||||
Math.min(this.canvas.height - 1, Math.round(relativeY * scaleY)),
|
||||
);
|
||||
return { x, y };
|
||||
}
|
||||
private setupEventListeners(): void {
|
||||
this.canvas.tabIndex = 1;
|
||||
this.canvas.style.outline = "none";
|
||||
|
||||
// Mouse events
|
||||
this.canvas.addEventListener("mousedown", this.boundHandlers.mouseDown);
|
||||
this.canvas.addEventListener("mouseup", this.boundHandlers.mouseUp);
|
||||
this.canvas.addEventListener("mousemove", this.boundHandlers.mouseMove);
|
||||
this.canvas.addEventListener("mouseenter", this.boundHandlers.mouseEnter);
|
||||
this.canvas.addEventListener("wheel", this.boundHandlers.wheel);
|
||||
this.canvas.addEventListener("contextmenu", this.boundHandlers.contextMenu);
|
||||
|
||||
// Touch events
|
||||
this.canvas.addEventListener("touchstart", this.boundHandlers.touchStart);
|
||||
this.canvas.addEventListener("touchmove", this.boundHandlers.touchMove);
|
||||
this.canvas.addEventListener("touchend", this.boundHandlers.touchEnd);
|
||||
|
||||
// Keyboard events
|
||||
this.canvas.addEventListener("keydown", this.boundHandlers.keyDown);
|
||||
this.canvas.addEventListener("keyup", this.boundHandlers.keyUp);
|
||||
this.canvas.addEventListener("paste", this.boundHandlers.paste);
|
||||
this.canvas.addEventListener("copy", this.boundHandlers.copy);
|
||||
|
||||
// Focus events
|
||||
this.canvas.addEventListener("focus", this.boundHandlers.focus);
|
||||
this.canvas.addEventListener("blur", this.boundHandlers.blur);
|
||||
this.canvas.addEventListener("click", this.boundHandlers.click);
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener("keydown", this.boundHandlers.globalKeyDown);
|
||||
}
|
||||
private updateVisualIndicator(active: boolean): void {
|
||||
if (!this.canvas.parentElement) return;
|
||||
const controls = this.canvas.parentElement.querySelector(
|
||||
"#rdpControls",
|
||||
) as HTMLElement;
|
||||
if (!controls) return;
|
||||
controls.style.borderBottom = active ? "2px solid #4CAF50" : "none";
|
||||
}
|
||||
private handleGlobalKeyDown(e: KeyboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
// F11 for fullscreen toggle
|
||||
if (e.key === "F11") {
|
||||
e.preventDefault();
|
||||
if (window.toggleFullscreen) {
|
||||
window.toggleFullscreen();
|
||||
}
|
||||
}
|
||||
// Ctrl+Alt+Enter for fullscreen toggle
|
||||
else if (e.ctrlKey && e.altKey && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (window.toggleFullscreen) {
|
||||
window.toggleFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleFocus(): void {
|
||||
this.isActive = true;
|
||||
this.updateVisualIndicator(true);
|
||||
// Trigger clipboard sync when canvas gains focus
|
||||
this.requestClipboardSync();
|
||||
}
|
||||
|
||||
private handleBlur(): void {
|
||||
this.isActive = false;
|
||||
this.releaseAllKeys();
|
||||
this.updateVisualIndicator(false);
|
||||
}
|
||||
|
||||
private handleClick(): void {
|
||||
this.canvas.focus();
|
||||
// Also sync clipboard on click to ensure paste works
|
||||
this.requestClipboardSync();
|
||||
}
|
||||
|
||||
private handlePaste(event: ClipboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
|
||||
// Only prevent default if we successfully handle the paste
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) return;
|
||||
|
||||
const text = clipboardData.getData("text/plain");
|
||||
if (!text) return;
|
||||
|
||||
// Prevent the default paste behavior only after we have the text
|
||||
event.preventDefault();
|
||||
|
||||
// Send Ctrl+V combination to RDP session first to maintain consistency
|
||||
this.sendPasteKeyCombination();
|
||||
|
||||
// Then send the actual text content
|
||||
this.sendTextAsKeystrokes(text);
|
||||
}
|
||||
|
||||
private handleCopy(event: ClipboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
|
||||
// Send Ctrl+C combination to RDP session
|
||||
this.sendCopyKeyCombination();
|
||||
|
||||
// Let the browser handle the actual copy operation
|
||||
// Don't prevent default - we want the browser to copy from the RDP canvas
|
||||
}
|
||||
|
||||
private sendPasteKeyCombination(): void {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
|
||||
try {
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
|
||||
// Send Ctrl (or Cmd on Mac) key down
|
||||
const ctrlScancode = this.isMacOS()
|
||||
? this.codeToScancode.MetaLeft
|
||||
: this.codeToScancode.ControlLeft;
|
||||
const vScancode = this.codeToScancode.KeyV;
|
||||
|
||||
if (ctrlScancode && vScancode) {
|
||||
// Ctrl/Cmd down
|
||||
const ctrlDown = this.ironrdp.DeviceEvent.keyPressed(ctrlScancode);
|
||||
transaction.addEvent(ctrlDown);
|
||||
|
||||
// V down
|
||||
const vDown = this.ironrdp.DeviceEvent.keyPressed(vScancode);
|
||||
transaction.addEvent(vDown);
|
||||
|
||||
// V up
|
||||
const vUp = this.ironrdp.DeviceEvent.keyReleased(vScancode);
|
||||
transaction.addEvent(vUp);
|
||||
|
||||
// Ctrl/Cmd up
|
||||
const ctrlUp = this.ironrdp.DeviceEvent.keyReleased(ctrlScancode);
|
||||
transaction.addEvent(ctrlUp);
|
||||
|
||||
this.session.applyInputs(transaction);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error sending paste key combination:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private sendCopyKeyCombination(): void {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
|
||||
try {
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
|
||||
// Send Ctrl (or Cmd on Mac) key down
|
||||
const ctrlScancode = this.isMacOS()
|
||||
? this.codeToScancode.MetaLeft
|
||||
: this.codeToScancode.ControlLeft;
|
||||
const cScancode = this.codeToScancode.KeyC;
|
||||
|
||||
if (ctrlScancode && cScancode) {
|
||||
// Ctrl/Cmd down
|
||||
const ctrlDown = this.ironrdp.DeviceEvent.keyPressed(ctrlScancode);
|
||||
transaction.addEvent(ctrlDown);
|
||||
|
||||
// C down
|
||||
const cDown = this.ironrdp.DeviceEvent.keyPressed(cScancode);
|
||||
transaction.addEvent(cDown);
|
||||
|
||||
// C up
|
||||
const cUp = this.ironrdp.DeviceEvent.keyReleased(cScancode);
|
||||
transaction.addEvent(cUp);
|
||||
|
||||
// Ctrl/Cmd up
|
||||
const ctrlUp = this.ironrdp.DeviceEvent.keyReleased(ctrlScancode);
|
||||
transaction.addEvent(ctrlUp);
|
||||
|
||||
this.session.applyInputs(transaction);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error sending copy key combination:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private sendTextAsKeystrokes(text: string): void {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
|
||||
try {
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
|
||||
// Send each character as unicode event
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.unicode(charCode);
|
||||
transaction.addEvent(deviceEvent);
|
||||
}
|
||||
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending paste text:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private requestClipboardSync(): void {
|
||||
// Notify the WASM bridge to check and sync clipboard, only for chrome for now, firefox and safari have issues with this
|
||||
if (!/Chrome/.test(navigator.userAgent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
window.IronRDPBridge &&
|
||||
(window.IronRDPBridge as any).checkAndSendClipboard
|
||||
) {
|
||||
setTimeout(() => {
|
||||
(window.IronRDPBridge as any).checkAndSendClipboard();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
private handleMouseDown(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
this.canvas.focus();
|
||||
if (!this.isActive) {
|
||||
this.isActive = true;
|
||||
}
|
||||
|
||||
const { x, y } = this.getCanvasCoordinates(event.clientX, event.clientY);
|
||||
const button = this.mouseButtonMap[event.button];
|
||||
if (button === undefined) return;
|
||||
if (this.mouseButtonStates[event.button]) return;
|
||||
this.mouseButtonStates[event.button] = true;
|
||||
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
|
||||
// Always send mouse position first, then the button press
|
||||
const moveEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
|
||||
const clickEvent = this.ironrdp.DeviceEvent.mouseButtonPressed(button);
|
||||
|
||||
transaction.addEvent(moveEvent);
|
||||
transaction.addEvent(clickEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
|
||||
this.currentMouseX = x;
|
||||
this.currentMouseY = y;
|
||||
} catch (err) {
|
||||
console.error("Error sending mouse down:", err);
|
||||
}
|
||||
}
|
||||
private handleMouseUp(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
const button = this.mouseButtonMap[event.button];
|
||||
if (button === undefined) return;
|
||||
if (!this.mouseButtonStates[event.button]) return;
|
||||
this.mouseButtonStates[event.button] = false;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.mouseButtonReleased(button);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending mouse up:", err);
|
||||
}
|
||||
}
|
||||
private handleMouseEnter(event: MouseEvent): void {
|
||||
// Always sync position when entering
|
||||
this.handleMouseMove(event);
|
||||
}
|
||||
|
||||
private handleMouseMove(event: MouseEvent): void {
|
||||
const { x, y } = this.getCanvasCoordinates(event.clientX, event.clientY);
|
||||
|
||||
// Skip if position hasn't changed
|
||||
if (x === this.currentMouseX && y === this.currentMouseY) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentMouseX = x;
|
||||
this.currentMouseY = y;
|
||||
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending mouse move:", err);
|
||||
}
|
||||
}
|
||||
private handleWheel(event: WheelEvent): void {
|
||||
event.preventDefault();
|
||||
if (!this.isActive) return;
|
||||
// Calculate rotation units (120 units = 1 notch)
|
||||
const delta = event.deltaY > 0 ? -1 : 1;
|
||||
const rotationUnits = delta * 120;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.wheelRotations(
|
||||
true,
|
||||
rotationUnits,
|
||||
);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending wheel event:", err);
|
||||
}
|
||||
}
|
||||
private handleKeyDown(event: KeyboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
|
||||
// For clipboard operations, don't prevent default to allow clipboard events to fire
|
||||
const isClipboardPaste =
|
||||
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v";
|
||||
const isClipboardCopy =
|
||||
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c";
|
||||
|
||||
if (!isClipboardPaste && !isClipboardCopy) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Don't send clipboard combinations here - let the clipboard events handle them (only for Chromium browsers)
|
||||
const isChromium = /Chrome/.test(navigator.userAgent);
|
||||
if ((isClipboardPaste || isClipboardCopy) && isChromium) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scancode = this.codeToScancode[event.code];
|
||||
if (scancode !== undefined) {
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.keyPressed(scancode);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending key down:", err);
|
||||
}
|
||||
} else if (event.key.length === 1) {
|
||||
// For printable characters not in our scancode map, use unicode
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const charCode = event.key.charCodeAt(0);
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.unicode(charCode);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending unicode char:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeyUp(event: KeyboardEvent): void {
|
||||
if (!this.isActive) return;
|
||||
|
||||
// For clipboard operations, don't prevent default to allow clipboard events to fire
|
||||
const isClipboardPaste =
|
||||
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v";
|
||||
const isClipboardCopy =
|
||||
(event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c";
|
||||
|
||||
if (!isClipboardPaste && !isClipboardCopy) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Don't send clipboard combinations here - let the clipboard events handle them
|
||||
const isChromium = /Chrome/.test(navigator.userAgent);
|
||||
if ((isClipboardPaste || isClipboardCopy) && isChromium) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scancode = this.codeToScancode[event.code];
|
||||
if (scancode === undefined) return;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.keyReleased(scancode);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error sending key up:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private handleTouchStart(event: TouchEvent): void {
|
||||
event.preventDefault();
|
||||
if (event.touches.length !== 1) return;
|
||||
const touch = event.touches[0];
|
||||
const { x, y } = this.getCanvasCoordinates(touch.clientX, touch.clientY);
|
||||
this.touchState.lastX = x;
|
||||
this.touchState.lastY = y;
|
||||
this.touchState.touching = true;
|
||||
// Simulate mouse down
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const moveEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
|
||||
const clickEvent = this.ironrdp.DeviceEvent.mouseButtonPressed(0);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(moveEvent);
|
||||
transaction.addEvent(clickEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
|
||||
this.currentMouseX = x;
|
||||
this.currentMouseY = y;
|
||||
} catch (err) {
|
||||
console.error("Error handling touch start:", err);
|
||||
}
|
||||
}
|
||||
private handleTouchMove(event: TouchEvent): void {
|
||||
event.preventDefault();
|
||||
if (!this.touchState.touching || event.touches.length !== 1) return;
|
||||
const touch = event.touches[0];
|
||||
const { x, y } = this.getCanvasCoordinates(touch.clientX, touch.clientY);
|
||||
if (x === this.touchState.lastX && y === this.touchState.lastY) return;
|
||||
this.touchState.lastX = x;
|
||||
this.touchState.lastY = y;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.mouseMove(x, y);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
|
||||
this.currentMouseX = x;
|
||||
this.currentMouseY = y;
|
||||
} catch (err) {
|
||||
console.error("Error handling touch move:", err);
|
||||
}
|
||||
}
|
||||
private handleTouchEnd(event: TouchEvent): void {
|
||||
event.preventDefault();
|
||||
if (!this.touchState.touching) return;
|
||||
this.touchState.touching = false;
|
||||
// Simulate mouse up
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.mouseButtonReleased(0);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error handling touch end:", err);
|
||||
}
|
||||
}
|
||||
private releaseAllKeys(): void {
|
||||
this.keyStates.forEach((pressed, code) => {
|
||||
if (!pressed) return;
|
||||
const scancode = this.codeToScancode[code];
|
||||
if (scancode === undefined) return;
|
||||
try {
|
||||
if (!this.session || !this.ironrdp) return;
|
||||
const deviceEvent = this.ironrdp.DeviceEvent.keyReleased(scancode);
|
||||
const transaction = new this.ironrdp.InputTransaction();
|
||||
transaction.addEvent(deviceEvent);
|
||||
this.session.applyInputs(transaction);
|
||||
} catch (err) {
|
||||
console.error("Error releasing key:", err);
|
||||
}
|
||||
});
|
||||
this.keyStates.clear();
|
||||
}
|
||||
destroy(): void {
|
||||
// Remove all event listeners using the same bound functions
|
||||
this.canvas.removeEventListener("mousedown", this.boundHandlers.mouseDown);
|
||||
this.canvas.removeEventListener("mouseup", this.boundHandlers.mouseUp);
|
||||
this.canvas.removeEventListener("mousemove", this.boundHandlers.mouseMove);
|
||||
this.canvas.removeEventListener(
|
||||
"mouseenter",
|
||||
this.boundHandlers.mouseEnter,
|
||||
);
|
||||
this.canvas.removeEventListener("wheel", this.boundHandlers.wheel);
|
||||
this.canvas.removeEventListener(
|
||||
"contextmenu",
|
||||
this.boundHandlers.contextMenu,
|
||||
);
|
||||
this.canvas.removeEventListener(
|
||||
"touchstart",
|
||||
this.boundHandlers.touchStart,
|
||||
);
|
||||
this.canvas.removeEventListener("touchmove", this.boundHandlers.touchMove);
|
||||
this.canvas.removeEventListener("touchend", this.boundHandlers.touchEnd);
|
||||
this.canvas.removeEventListener("keydown", this.boundHandlers.keyDown);
|
||||
this.canvas.removeEventListener("keyup", this.boundHandlers.keyUp);
|
||||
this.canvas.removeEventListener("paste", this.boundHandlers.paste);
|
||||
this.canvas.removeEventListener("copy", this.boundHandlers.copy);
|
||||
this.canvas.removeEventListener("focus", this.boundHandlers.focus);
|
||||
this.canvas.removeEventListener("blur", this.boundHandlers.blur);
|
||||
this.canvas.removeEventListener("click", this.boundHandlers.click);
|
||||
document.removeEventListener("keydown", this.boundHandlers.globalKeyDown);
|
||||
|
||||
this.releaseAllKeys();
|
||||
this.isActive = false;
|
||||
}
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).IronRDPInputHandler = IronRDPInputHandler;
|
||||
}
|
||||
450
src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts
Normal file
450
src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
export interface IronRDPModule {
|
||||
SessionBuilder: new () => SessionBuilder;
|
||||
DesktopSize: new (width: number, height: number) => DesktopSize;
|
||||
ClipboardData?: new () => ClipboardData;
|
||||
default?: () => Promise<void>;
|
||||
init?: () => Promise<void>;
|
||||
}
|
||||
interface DesktopSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface SessionBuilder {
|
||||
username(user: string): SessionBuilder;
|
||||
password(pwd: string): SessionBuilder;
|
||||
destination(dest: string): SessionBuilder;
|
||||
serverDomain(domain: string): SessionBuilder;
|
||||
desktopSize(size: DesktopSize): SessionBuilder;
|
||||
renderCanvas(canvas: HTMLCanvasElement): SessionBuilder;
|
||||
proxyAddress(url: string): SessionBuilder;
|
||||
authToken(token: string): SessionBuilder;
|
||||
setCursorStyleCallback(cb: (style: string) => void): void;
|
||||
setCursorStyleCallbackContext(ctx: unknown): void;
|
||||
remoteClipboardChangedCallback(cb: (data: ClipboardData) => void): void;
|
||||
forceClipboardUpdateCallback(cb: () => void): void;
|
||||
connect(): Promise<RDPSession>;
|
||||
}
|
||||
export interface RDPSession {
|
||||
run(): Promise<TerminationInfo>;
|
||||
shutdown(): void;
|
||||
sendInput(input: unknown): void;
|
||||
onClipboardPaste?(content: ClipboardData): Promise<void>;
|
||||
inputHandler?: IronRDPInputHandler;
|
||||
}
|
||||
interface TerminationInfo {
|
||||
reason(): string;
|
||||
}
|
||||
interface ClipboardData {
|
||||
items(): ClipboardItem[];
|
||||
addText(mimeType: string, text: string): void;
|
||||
addBinary(mimeType: string, binary: Uint8Array): void;
|
||||
isEmpty(): boolean;
|
||||
}
|
||||
interface ClipboardItem {
|
||||
mimeType(): string;
|
||||
value(): string;
|
||||
}
|
||||
interface RDPConfig {
|
||||
username: string;
|
||||
password: string;
|
||||
domain?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
enable_tls: boolean;
|
||||
enable_credssp: boolean;
|
||||
enable_nla: boolean;
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
IronRDPBridge: IronRDPWASMBridge;
|
||||
IronRDPInputHandler?: new (
|
||||
ironrdp: IronRDPModule,
|
||||
session: RDPSession,
|
||||
canvas: HTMLCanvasElement,
|
||||
) => IronRDPInputHandler;
|
||||
initializeIronRDP: () => Promise<boolean>;
|
||||
onIronRDPReady?: () => void;
|
||||
createRDCleanPathProxy?: (
|
||||
hostname: string,
|
||||
port: number,
|
||||
) => Promise<string>;
|
||||
}
|
||||
}
|
||||
interface IronRDPInputHandler {
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const IRON_RDP_PKG = "/ironrdp-pkg/ironrdp_web.js";
|
||||
|
||||
export class IronRDPWASMBridge {
|
||||
private ironrdp: IronRDPModule | null = null;
|
||||
private initialized = false;
|
||||
private sessions = new Map<string, RDPSession>();
|
||||
private lastClipboardContent = "";
|
||||
private clipboardEventListeners: (() => void)[] = [];
|
||||
|
||||
// Expose clipboard sync method for input handler
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
try {
|
||||
// @ts-ignore - Dynamic import from public directory
|
||||
const ironrdpModule = (await import(
|
||||
/* webpackIgnore: true */ IRON_RDP_PKG
|
||||
)) as IronRDPModule;
|
||||
try {
|
||||
if (ironrdpModule.default) {
|
||||
await ironrdpModule.default();
|
||||
}
|
||||
} catch (e) {
|
||||
if (ironrdpModule.init) {
|
||||
await ironrdpModule.init();
|
||||
}
|
||||
}
|
||||
this.ironrdp = ironrdpModule;
|
||||
this.initialized = true;
|
||||
if (window.onIronRDPReady) {
|
||||
window.onIronRDPReady();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load IronRDP WASM:", error);
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
async connect(
|
||||
hostname: string,
|
||||
port: number,
|
||||
username: string,
|
||||
password: string,
|
||||
canvas: HTMLCanvasElement,
|
||||
enableClipboard = true,
|
||||
netbirdClient?: {
|
||||
createRDPProxy: (hostname: string, port: string) => Promise<string>;
|
||||
},
|
||||
): Promise<string> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
if (!this.ironrdp) {
|
||||
throw new Error("IronRDP module not loaded");
|
||||
}
|
||||
const sessionId = `${hostname}:${port}_${Date.now()}`;
|
||||
try {
|
||||
const config: RDPConfig = {
|
||||
username,
|
||||
password,
|
||||
domain: "",
|
||||
width: canvas.width || 1024,
|
||||
height: canvas.height || 768,
|
||||
enable_tls: true,
|
||||
enable_credssp: true,
|
||||
enable_nla: true,
|
||||
};
|
||||
const builder = new this.ironrdp.SessionBuilder();
|
||||
builder
|
||||
.username(username)
|
||||
.password(password)
|
||||
.destination(`${hostname}:${port}`);
|
||||
if (config.domain) {
|
||||
builder.serverDomain(config.domain);
|
||||
}
|
||||
const desktopSize = new this.ironrdp.DesktopSize(
|
||||
config.width,
|
||||
config.height,
|
||||
);
|
||||
builder.desktopSize(desktopSize);
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
}
|
||||
builder.renderCanvas(canvas);
|
||||
}
|
||||
builder.setCursorStyleCallback((style: string) => {});
|
||||
builder.setCursorStyleCallbackContext(null);
|
||||
if (enableClipboard) {
|
||||
this.setupClipboard(builder);
|
||||
}
|
||||
// RDCleanPath proxy is required for IronRDP
|
||||
if (!netbirdClient || !netbirdClient.createRDPProxy) {
|
||||
throw new Error("NetBird client with RDP proxy support is required");
|
||||
}
|
||||
const proxyURL = await netbirdClient.createRDPProxy(
|
||||
hostname,
|
||||
port.toString(),
|
||||
);
|
||||
builder.proxyAddress(proxyURL);
|
||||
builder.authToken("");
|
||||
const session = await builder.connect();
|
||||
this.sessions.set(sessionId, session);
|
||||
if (canvas) {
|
||||
this.attachInputHandler(session, canvas);
|
||||
}
|
||||
if (enableClipboard) {
|
||||
this.startClipboardEventListeners();
|
||||
}
|
||||
this.startSession(session, sessionId);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
console.error(`IronRDP connection failed:`, error);
|
||||
this.logIronError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private setupClipboard(builder: SessionBuilder): void {
|
||||
if (!this.ironrdp?.ClipboardData) {
|
||||
console.warn("ClipboardData class not available in IronRDP module");
|
||||
return;
|
||||
}
|
||||
builder.remoteClipboardChangedCallback((clipboardData: ClipboardData) => {
|
||||
this.handleRemoteClipboard(clipboardData);
|
||||
});
|
||||
builder.forceClipboardUpdateCallback(() => {
|
||||
this.handleLocalClipboardRequest();
|
||||
});
|
||||
}
|
||||
private attachInputHandler(
|
||||
session: RDPSession,
|
||||
canvas: HTMLCanvasElement,
|
||||
): void {
|
||||
if (!window.IronRDPInputHandler) {
|
||||
console.warn("IronRDPInputHandler not loaded - input will not work");
|
||||
return;
|
||||
}
|
||||
if (!this.ironrdp) {
|
||||
console.warn("IronRDP module not available");
|
||||
return;
|
||||
}
|
||||
session.inputHandler = new window.IronRDPInputHandler(
|
||||
this.ironrdp,
|
||||
session,
|
||||
canvas,
|
||||
);
|
||||
}
|
||||
private startSession(session: RDPSession, sessionId: string): void {
|
||||
session
|
||||
.run()
|
||||
.then((termInfo) => {
|
||||
this.cleanupSession(session, sessionId);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("IronRDP session error:", err);
|
||||
this.cleanupSession(session, sessionId);
|
||||
throw Error(err);
|
||||
});
|
||||
}
|
||||
private cleanupSession(session: RDPSession, sessionId: string): void {
|
||||
if (session.inputHandler) {
|
||||
session.inputHandler.destroy();
|
||||
}
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
// Stop clipboard event listeners if no active sessions
|
||||
if (this.sessions.size === 0) {
|
||||
this.stopClipboardEventListeners();
|
||||
}
|
||||
}
|
||||
private logIronError(error: unknown): void {
|
||||
const ironError = error as any;
|
||||
if (!ironError || !ironError.__wbg_ptr) return;
|
||||
try {
|
||||
if (ironError.backtrace) {
|
||||
console.error("IronRDP backtrace:", ironError.backtrace());
|
||||
}
|
||||
if (ironError.kind) {
|
||||
const errorKind = ironError.kind();
|
||||
const errorKindNames = [
|
||||
"General",
|
||||
"WrongPassword",
|
||||
"LogonFailure",
|
||||
"AccessDenied",
|
||||
"RDCleanPath",
|
||||
"ProxyConnect",
|
||||
"NegotiationFailure",
|
||||
];
|
||||
const errorKindName = errorKindNames[errorKind] || "Unknown";
|
||||
console.error("IronRDP error kind:", errorKindName, `(${errorKind})`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Could not extract IronError details:", e);
|
||||
}
|
||||
}
|
||||
disconnect(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
if (session.inputHandler) {
|
||||
session.inputHandler.destroy();
|
||||
session.inputHandler = undefined;
|
||||
}
|
||||
if (session.shutdown) {
|
||||
session.shutdown();
|
||||
}
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
// Stop clipboard event listeners if no active sessions
|
||||
if (this.sessions.size === 0) {
|
||||
this.stopClipboardEventListeners();
|
||||
}
|
||||
}
|
||||
private handleRemoteClipboard(clipboardData: ClipboardData): void {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
console.warn("Browser clipboard API not available");
|
||||
return;
|
||||
}
|
||||
if (!clipboardData.items) {
|
||||
console.error("clipboardData.items() method not found");
|
||||
return;
|
||||
}
|
||||
const items = clipboardData.items();
|
||||
if (items.length === 0) return;
|
||||
for (const item of items) {
|
||||
const mimeType = item.mimeType();
|
||||
const value = item.value();
|
||||
if (mimeType !== "text/plain") {
|
||||
continue;
|
||||
}
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(() => {
|
||||
//this.showClipboardNotification("Clipboard updated from RDP");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to copy to browser clipboard:", err);
|
||||
this.fallbackClipboardCopy(value);
|
||||
});
|
||||
return; // Only handle first text item
|
||||
}
|
||||
}
|
||||
private fallbackClipboardCopy(text: string): void {
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const success = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
if (success) {
|
||||
//this.showClipboardNotification("Clipboard updated from RDP");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fallback clipboard error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLocalClipboardRequest(): Promise<void> {
|
||||
if (!navigator.clipboard?.readText) {
|
||||
console.warn("Browser clipboard read API not available");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
if (clipboardText && clipboardText !== this.lastClipboardContent) {
|
||||
await this.sendClipboardToRDP(clipboardText);
|
||||
this.lastClipboardContent = clipboardText;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Could not read from clipboard:", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendClipboardToRDP(text: string): Promise<void> {
|
||||
if (!this.ironrdp?.ClipboardData) return;
|
||||
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
try {
|
||||
const clipboardData = new this.ironrdp.ClipboardData();
|
||||
clipboardData.addText("text/plain", text);
|
||||
|
||||
if (session.onClipboardPaste) {
|
||||
await session.onClipboardPaste(clipboardData);
|
||||
}
|
||||
//this.showClipboardNotification("Clipboard sent to RDP");
|
||||
} catch (err) {
|
||||
console.error("Failed to send clipboard to RDP:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
private startClipboardEventListeners(): void {
|
||||
if (this.clipboardEventListeners.length > 0) return;
|
||||
|
||||
// Listen for keyboard shortcuts (Ctrl+C, Ctrl+V, Ctrl+X)
|
||||
const handleKeyboardShortcut = async (event: KeyboardEvent) => {
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
["c", "x", "v"].includes(event.key.toLowerCase())
|
||||
) {
|
||||
// For copy/cut operations, check clipboard after delay
|
||||
if (["c", "x"].includes(event.key.toLowerCase())) {
|
||||
setTimeout(async () => {
|
||||
await this.checkAndSendClipboard();
|
||||
}, 100);
|
||||
}
|
||||
// For paste, check immediately to ensure up-to-date content
|
||||
else if (event.key.toLowerCase() === "v") {
|
||||
await this.checkAndSendClipboard();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for clipboard events (more reliable when available)
|
||||
const handleClipboardChange = async () => {
|
||||
await this.checkAndSendClipboard();
|
||||
};
|
||||
|
||||
// Listen for focus events - check clipboard when window regains focus
|
||||
const handleFocus = async () => {
|
||||
await this.checkAndSendClipboard();
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener("keydown", handleKeyboardShortcut);
|
||||
document.addEventListener("copy", handleClipboardChange);
|
||||
document.addEventListener("cut", handleClipboardChange);
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
// Store cleanup functions
|
||||
this.clipboardEventListeners = [
|
||||
() => document.removeEventListener("keydown", handleKeyboardShortcut),
|
||||
() => document.removeEventListener("copy", handleClipboardChange),
|
||||
() => document.removeEventListener("cut", handleClipboardChange),
|
||||
() => window.removeEventListener("focus", handleFocus),
|
||||
];
|
||||
}
|
||||
|
||||
private stopClipboardEventListeners(): void {
|
||||
this.clipboardEventListeners.forEach((cleanup) => cleanup());
|
||||
this.clipboardEventListeners = [];
|
||||
}
|
||||
|
||||
public async checkAndSendClipboard(): Promise<void> {
|
||||
if (!navigator.clipboard?.readText) return;
|
||||
|
||||
if (!/Chrome/.test(navigator.userAgent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
if (clipboardText && clipboardText !== this.lastClipboardContent) {
|
||||
await this.sendClipboardToRDP(clipboardText);
|
||||
this.lastClipboardContent = clipboardText;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore clipboard read errors - might be due to focus/permission issues
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.IronRDPBridge = new IronRDPWASMBridge();
|
||||
window.initializeIronRDP = async function (): Promise<boolean> {
|
||||
try {
|
||||
await window.IronRDPBridge.initialize();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize IronRDP:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
397
src/modules/remote-access/rdp/rdp-certificate-handler.ts
Normal file
397
src/modules/remote-access/rdp/rdp-certificate-handler.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* RDP Certificate Handler
|
||||
* Handles X.509 certificate validation and user acceptance for RDP connections
|
||||
*/
|
||||
export interface CertificateInfo {
|
||||
raw?: Uint8Array;
|
||||
fingerprint: string;
|
||||
hostname: string;
|
||||
subject?: string;
|
||||
issuer?: string;
|
||||
validFrom?: Date;
|
||||
validTo?: Date;
|
||||
serialNumber?: string;
|
||||
keySize?: number;
|
||||
}
|
||||
interface TrustedCertificate {
|
||||
fingerprint: string;
|
||||
hostname: string;
|
||||
addedAt: string;
|
||||
subject?: string;
|
||||
}
|
||||
export interface RDCleanPathResponse {
|
||||
ServerAddr?: string;
|
||||
ServerCertChain?: Uint8Array[];
|
||||
CertificateInfo?: CertificateInfo;
|
||||
}
|
||||
|
||||
export interface CertificateHandler {
|
||||
validateCertificate(certInfo: CertificateInfo, hostname?: string): Promise<boolean>;
|
||||
handleRDCleanPathResponse(response: RDCleanPathResponse): Promise<boolean>;
|
||||
}
|
||||
|
||||
export class RDPCertificateHandler implements CertificateHandler {
|
||||
private readonly STORAGE_KEY = 'netbird-rdp-trusted-certs';
|
||||
private modalElement: HTMLElement | null = null;
|
||||
/**
|
||||
* Handle RDCleanPath response containing server certificates
|
||||
*/
|
||||
async handleRDCleanPathResponse(response: RDCleanPathResponse): Promise<boolean> {
|
||||
if (!response.ServerCertChain || response.ServerCertChain.length === 0) {
|
||||
console.error('No certificate chain provided - rejecting connection for security');
|
||||
return false;
|
||||
}
|
||||
const serverAddr = response.ServerAddr || 'unknown';
|
||||
const hostname = serverAddr.split(':')[0];
|
||||
const certBytes = response.ServerCertChain[0];
|
||||
try {
|
||||
// Check if response already has parsed certificate info from Go proxy
|
||||
if (response.CertificateInfo) {
|
||||
return await this.validateCertificate(response.CertificateInfo, hostname);
|
||||
}
|
||||
// Fallback to parsing the raw certificate
|
||||
const certInfo = await this.parseCertificate(certBytes, hostname);
|
||||
return await this.validateCertificate(certInfo, hostname);
|
||||
} catch (error) {
|
||||
console.error('Certificate validation error:', error);
|
||||
return await this.promptRawCertificateAcceptance(certBytes, hostname);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Parse X.509 certificate bytes to extract relevant information
|
||||
* Note: For proper X.509 parsing, the Go proxy should provide parsed certificate info
|
||||
*/
|
||||
async parseCertificate(certBytes: Uint8Array, hostname: string): Promise<CertificateInfo> {
|
||||
const fingerprint = await this.calculateFingerprint(certBytes);
|
||||
// Basic certificate info - actual parsing should be done by Go proxy
|
||||
const certInfo: CertificateInfo = {
|
||||
raw: certBytes,
|
||||
fingerprint: fingerprint,
|
||||
hostname: hostname
|
||||
};
|
||||
// Try to extract basic info from certificate if not provided by proxy
|
||||
// This is a fallback - proper X.509 parsing should be done server-side
|
||||
const certString = new TextDecoder('latin1').decode(certBytes);
|
||||
const cnMatch = certString.match(/CN=([^,\0]+)/);
|
||||
if (cnMatch) {
|
||||
certInfo.subject = cnMatch[0];
|
||||
}
|
||||
return certInfo;
|
||||
}
|
||||
/**
|
||||
* Extract serial number from certificate bytes
|
||||
* Note: This is a placeholder - actual serial number extraction requires proper ASN.1 parsing
|
||||
*/
|
||||
private extractSerialNumber(certBytes: Uint8Array): string | undefined {
|
||||
// Serial number extraction should be done by the Go proxy
|
||||
// This is just a fallback that won't work reliably
|
||||
return undefined;
|
||||
}
|
||||
/**
|
||||
* Calculate SHA-256 fingerprint of certificate
|
||||
*/
|
||||
async calculateFingerprint(certBytes: Uint8Array): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', certBytes);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join(':')
|
||||
.toUpperCase();
|
||||
}
|
||||
/**
|
||||
* Validate certificate against stored trust database
|
||||
*/
|
||||
async validateCertificate(certInfo: CertificateInfo, hostname?: string): Promise<boolean> {
|
||||
const host = hostname || certInfo.hostname;
|
||||
const trustedCerts = this.loadTrustedCerts();
|
||||
if (trustedCerts[host]) {
|
||||
const stored = trustedCerts[host];
|
||||
if (stored.fingerprint === certInfo.fingerprint) {
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`Certificate for ${host} has changed!`);
|
||||
return await this.promptCertificateChange(host, certInfo, stored);
|
||||
}
|
||||
}
|
||||
console.log(`New certificate for ${host} - requesting user acceptance`);
|
||||
return await this.promptUserAcceptance(host, certInfo);
|
||||
}
|
||||
/**
|
||||
* Load trusted certificates from storage
|
||||
*/
|
||||
private loadTrustedCerts(): Record<string, TrustedCertificate> {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch (error) {
|
||||
console.error('Failed to load trusted certificates:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Save trusted certificate to storage
|
||||
*/
|
||||
private saveTrustedCert(hostname: string, certInfo: CertificateInfo): void {
|
||||
try {
|
||||
const trustedCerts = this.loadTrustedCerts();
|
||||
trustedCerts[hostname] = {
|
||||
fingerprint: certInfo.fingerprint,
|
||||
hostname: hostname,
|
||||
addedAt: new Date().toISOString(),
|
||||
subject: certInfo.subject
|
||||
};
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(trustedCerts));
|
||||
} catch (error) {
|
||||
console.error('Failed to save trusted certificate:', error);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Prompt user to accept a new certificate
|
||||
*/
|
||||
private async promptUserAcceptance(hostname: string, certInfo: CertificateInfo): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = this.createCertificateModal(hostname, certInfo, false);
|
||||
const acceptBtn = modal.querySelector('#cert-accept') as HTMLButtonElement;
|
||||
const rejectBtn = modal.querySelector('#cert-reject') as HTMLButtonElement;
|
||||
const rememberCheck = modal.querySelector('#cert-remember') as HTMLInputElement;
|
||||
acceptBtn.onclick = () => {
|
||||
const remember = rememberCheck.checked;
|
||||
if (remember) {
|
||||
this.saveTrustedCert(hostname, certInfo);
|
||||
}
|
||||
this.closeModal();
|
||||
resolve(true);
|
||||
};
|
||||
rejectBtn.onclick = () => {
|
||||
this.closeModal();
|
||||
resolve(false);
|
||||
};
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Prompt user when certificate has changed
|
||||
*/
|
||||
private async promptCertificateChange(
|
||||
hostname: string,
|
||||
newCert: CertificateInfo,
|
||||
oldCert: TrustedCertificate
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const modal = this.createCertificateModal(hostname, newCert, true);
|
||||
// Add warning about certificate change
|
||||
const warningDiv = modal.querySelector('.cert-warning') as HTMLElement;
|
||||
warningDiv.innerHTML = `
|
||||
<strong>⚠️ Certificate has changed!</strong><br>
|
||||
<small>Previous fingerprint: ${oldCert.fingerprint.substring(0, 32)}...</small>
|
||||
`;
|
||||
const acceptBtn = modal.querySelector('#cert-accept') as HTMLButtonElement;
|
||||
const rejectBtn = modal.querySelector('#cert-reject') as HTMLButtonElement;
|
||||
const rememberCheck = modal.querySelector('#cert-remember') as HTMLInputElement;
|
||||
acceptBtn.onclick = () => {
|
||||
const remember = rememberCheck.checked;
|
||||
if (remember) {
|
||||
this.saveTrustedCert(hostname, newCert);
|
||||
}
|
||||
this.closeModal();
|
||||
resolve(true);
|
||||
};
|
||||
rejectBtn.onclick = () => {
|
||||
this.closeModal();
|
||||
resolve(false);
|
||||
};
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Prompt for raw certificate acceptance when parsing fails
|
||||
*/
|
||||
private async promptRawCertificateAcceptance(certBytes: Uint8Array, hostname: string): Promise<boolean> {
|
||||
const fingerprint = await this.calculateFingerprint(certBytes);
|
||||
const certInfo: CertificateInfo = {
|
||||
raw: certBytes,
|
||||
fingerprint: fingerprint,
|
||||
hostname: hostname,
|
||||
subject: 'Unable to parse',
|
||||
issuer: 'Unable to parse'
|
||||
};
|
||||
return this.promptUserAcceptance(hostname, certInfo);
|
||||
}
|
||||
/**
|
||||
* Create certificate acceptance modal
|
||||
*/
|
||||
private createCertificateModal(hostname: string, certInfo: CertificateInfo, isChange: boolean): HTMLElement {
|
||||
// Remove any existing modal
|
||||
this.closeModal();
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'rdp-cert-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="rdp-cert-overlay"></div>
|
||||
<div class="rdp-cert-dialog">
|
||||
<h2>RDP Certificate Verification</h2>
|
||||
<div class="cert-warning" style="color: #ff9800; margin-bottom: 15px;"></div>
|
||||
<p>The server <strong>${hostname}</strong> is presenting a certificate:</p>
|
||||
<div class="cert-details">
|
||||
<table>
|
||||
<tr><td><strong>Subject:</strong></td><td>${certInfo.subject || 'Unknown'}</td></tr>
|
||||
<tr><td><strong>Issuer:</strong></td><td>${certInfo.issuer || 'Unknown'}</td></tr>
|
||||
${certInfo.serialNumber ? `<tr><td><strong>Serial:</strong></td><td style="font-family: monospace; font-size: 0.9em;">${certInfo.serialNumber}</td></tr>` : ''}
|
||||
<tr><td><strong>SHA-256:</strong></td><td style="font-family: monospace; font-size: 0.9em;">
|
||||
${certInfo.fingerprint}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cert-question">
|
||||
<p>Do you trust this certificate?</p>
|
||||
<label>
|
||||
<input type="checkbox" id="cert-remember" checked>
|
||||
Remember this certificate for future connections
|
||||
</label>
|
||||
</div>
|
||||
<div class="cert-buttons">
|
||||
<button id="cert-reject" class="cert-btn cert-btn-reject">Reject</button>
|
||||
<button id="cert-accept" class="cert-btn cert-btn-accept">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
// Add styles if not already present
|
||||
if (!document.querySelector('#rdp-cert-styles')) {
|
||||
const styles = document.createElement('style');
|
||||
styles.id = 'rdp-cert-styles';
|
||||
styles.textContent = this.getModalStyles();
|
||||
document.head.appendChild(styles);
|
||||
}
|
||||
document.body.appendChild(modal);
|
||||
this.modalElement = modal;
|
||||
return modal;
|
||||
}
|
||||
/**
|
||||
* Close the certificate modal
|
||||
*/
|
||||
private closeModal(): void {
|
||||
if (this.modalElement) {
|
||||
document.body.removeChild(this.modalElement);
|
||||
this.modalElement = null;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get modal CSS styles
|
||||
*/
|
||||
private getModalStyles(): string {
|
||||
return `
|
||||
.rdp-cert-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10000;
|
||||
}
|
||||
.rdp-cert-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
.rdp-cert-dialog {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.rdp-cert-dialog h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #0078d4;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.cert-details {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.cert-details table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.cert-details td {
|
||||
padding: 5px 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.cert-details td:first-child {
|
||||
width: 100px;
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
}
|
||||
.cert-question {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.cert-question label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cert-question input {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.cert-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.cert-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.cert-btn-accept {
|
||||
background: #0078d4;
|
||||
color: white;
|
||||
}
|
||||
.cert-btn-accept:hover {
|
||||
background: #106ebe;
|
||||
}
|
||||
.cert-btn-reject {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
.cert-btn-reject:hover {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
/**
|
||||
* Clear all trusted certificates
|
||||
*/
|
||||
clearTrustedCerts(): void {
|
||||
localStorage.removeItem(this.STORAGE_KEY);
|
||||
console.log('Cleared all trusted RDP certificates');
|
||||
}
|
||||
/**
|
||||
* Get list of trusted certificates
|
||||
*/
|
||||
getTrustedCerts(): TrustedCertificate[] {
|
||||
const trustedCerts = this.loadTrustedCerts();
|
||||
return Object.values(trustedCerts);
|
||||
}
|
||||
}
|
||||
// Export as global for compatibility
|
||||
declare global {
|
||||
interface Window {
|
||||
RDPCertificateHandler: typeof RDPCertificateHandler;
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.RDPCertificateHandler = RDPCertificateHandler;
|
||||
}
|
||||
320
src/modules/remote-access/rdp/useRDPCertificateHandler.ts
Normal file
320
src/modules/remote-access/rdp/useRDPCertificateHandler.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export interface CertificateInfo {
|
||||
raw?: Uint8Array;
|
||||
fingerprint: string;
|
||||
hostname: string;
|
||||
subject?: string;
|
||||
issuer?: string;
|
||||
validFrom?: Date;
|
||||
validTo?: Date;
|
||||
serialNumber?: string;
|
||||
keySize?: number;
|
||||
}
|
||||
|
||||
interface TrustedCertificate {
|
||||
fingerprint: string;
|
||||
hostname: string;
|
||||
addedAt: string;
|
||||
subject?: string;
|
||||
}
|
||||
|
||||
export interface RDCleanPathResponse {
|
||||
ServerAddr?: string;
|
||||
ServerCertChain?: Uint8Array[];
|
||||
CertificateInfo?: CertificateInfo;
|
||||
}
|
||||
|
||||
export interface CertificatePromptInfo {
|
||||
hostname: string;
|
||||
certificate: CertificateInfo;
|
||||
isChange: boolean;
|
||||
previousCertificate?: TrustedCertificate;
|
||||
}
|
||||
|
||||
export interface CertificateValidationResult {
|
||||
isValid: boolean;
|
||||
needsUserConfirmation: boolean;
|
||||
promptInfo?: CertificatePromptInfo;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "netbird-rdp-trusted-certs";
|
||||
|
||||
export const useRDPCertificateHandler = () => {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const calculateFingerprint = useCallback(
|
||||
async (certBytes: Uint8Array): Promise<string> => {
|
||||
try {
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", certBytes);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const fingerprint = hashArray
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(":")
|
||||
.toUpperCase();
|
||||
return fingerprint;
|
||||
} catch (error) {
|
||||
return "FINGERPRINT_CALCULATION_FAILED";
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const parseCertificate = useCallback(
|
||||
async (
|
||||
certBytes: Uint8Array,
|
||||
hostname: string,
|
||||
): Promise<CertificateInfo> => {
|
||||
const fingerprint = await calculateFingerprint(certBytes);
|
||||
const certInfo: CertificateInfo = {
|
||||
raw: certBytes,
|
||||
fingerprint,
|
||||
hostname,
|
||||
};
|
||||
|
||||
try {
|
||||
const certString = new TextDecoder("latin1").decode(certBytes);
|
||||
|
||||
// Parse subject (CN)
|
||||
const cnMatch = certString.match(/CN=([^,\0\x00-\x1f]+)/);
|
||||
if (cnMatch) {
|
||||
certInfo.subject = `CN=${cnMatch[1].trim()}`;
|
||||
}
|
||||
|
||||
// Parse issuer - look for issuer field
|
||||
const issuerMatch = certString.match(
|
||||
/(?:issuer|Issuer).*?CN=([^,\0\x00-\x1f]+)/i,
|
||||
);
|
||||
if (issuerMatch) {
|
||||
certInfo.issuer = `CN=${issuerMatch[1].trim()}`;
|
||||
} else {
|
||||
// Fallback: look for second CN occurrence (often issuer)
|
||||
const cnMatches = [...certString.matchAll(/CN=([^,\0\x00-\x1f]+)/g)];
|
||||
if (cnMatches.length > 1) {
|
||||
certInfo.issuer = `CN=${cnMatches[1][1].trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate key size based on certificate structure and length
|
||||
if (certBytes.length > 100) {
|
||||
// Look for RSA signature patterns or use length heuristic
|
||||
if (certBytes.length > 1400) {
|
||||
certInfo.keySize = 2048;
|
||||
} else if (certBytes.length > 1000) {
|
||||
certInfo.keySize = 1024;
|
||||
} else if (certBytes.length > 600) {
|
||||
certInfo.keySize = 1024;
|
||||
} else {
|
||||
certInfo.keySize = 512;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse serial number (look for sequence of hex bytes)
|
||||
const serialMatch = certString.match(
|
||||
/[\x02][\x01-\x10]([\x00-\xff]{1,16})/,
|
||||
);
|
||||
if (serialMatch && serialMatch[1]) {
|
||||
const serialBytes = Array.from(serialMatch[1], (char) =>
|
||||
char.charCodeAt(0),
|
||||
);
|
||||
certInfo.serialNumber = serialBytes
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join(":")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// Try to parse validity dates (basic ASN.1 time format)
|
||||
// Look for UTCTime or GeneralizedTime patterns
|
||||
const timePattern =
|
||||
/[\x17\x18][\x0d\x0f](\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z?/g;
|
||||
const timeMatches = [...certString.matchAll(timePattern)];
|
||||
|
||||
if (timeMatches.length >= 2) {
|
||||
const parseTime = (match: RegExpMatchArray) => {
|
||||
let year = parseInt(match[1]);
|
||||
// Handle 2-digit years (UTCTime format)
|
||||
if (year < 50) year += 2000;
|
||||
else if (year < 100) year += 1900;
|
||||
|
||||
const month = parseInt(match[2]) - 1; // JS months are 0-based
|
||||
const day = parseInt(match[3]);
|
||||
const hour = parseInt(match[4]);
|
||||
const minute = parseInt(match[5]);
|
||||
const second = parseInt(match[6]);
|
||||
|
||||
return new Date(year, month, day, hour, minute, second);
|
||||
};
|
||||
|
||||
certInfo.validFrom = parseTime(timeMatches[0]);
|
||||
certInfo.validTo = parseTime(timeMatches[1]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse certificate details:", error);
|
||||
}
|
||||
|
||||
return certInfo;
|
||||
},
|
||||
[calculateFingerprint],
|
||||
);
|
||||
|
||||
const getTrustedCerts = useCallback((): Record<
|
||||
string,
|
||||
TrustedCertificate
|
||||
> => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch (error) {
|
||||
console.error("Failed to load trusted certificates:", error);
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveTrustedCert = useCallback(
|
||||
(hostname: string, certInfo: CertificateInfo): void => {
|
||||
try {
|
||||
const trustedCerts = getTrustedCerts();
|
||||
trustedCerts[hostname] = {
|
||||
fingerprint: certInfo.fingerprint,
|
||||
hostname,
|
||||
addedAt: new Date().toISOString(),
|
||||
subject: certInfo.subject,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(trustedCerts));
|
||||
} catch (error) {
|
||||
console.error("Failed to save trusted certificate:", error);
|
||||
}
|
||||
},
|
||||
[getTrustedCerts],
|
||||
);
|
||||
|
||||
const validateCertificate = useCallback(
|
||||
async (
|
||||
certInfo: CertificateInfo,
|
||||
hostname?: string,
|
||||
): Promise<CertificateValidationResult> => {
|
||||
const host = hostname || certInfo.hostname;
|
||||
const trustedCerts = getTrustedCerts();
|
||||
const stored = trustedCerts[host];
|
||||
|
||||
if (stored) {
|
||||
if (stored.fingerprint === certInfo.fingerprint) {
|
||||
return { isValid: true, needsUserConfirmation: false };
|
||||
}
|
||||
|
||||
console.warn(`Certificate for ${host} has changed!`);
|
||||
return {
|
||||
isValid: false,
|
||||
needsUserConfirmation: true,
|
||||
promptInfo: {
|
||||
hostname: host,
|
||||
certificate: certInfo,
|
||||
isChange: true,
|
||||
previousCertificate: stored,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
needsUserConfirmation: true,
|
||||
promptInfo: {
|
||||
hostname: host,
|
||||
certificate: certInfo,
|
||||
isChange: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
[getTrustedCerts],
|
||||
);
|
||||
|
||||
const handleRDCleanPathResponse = useCallback(
|
||||
async (
|
||||
response: RDCleanPathResponse,
|
||||
): Promise<CertificateValidationResult> => {
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
if (!response.ServerCertChain?.length) {
|
||||
return { isValid: false, needsUserConfirmation: false };
|
||||
}
|
||||
|
||||
const serverAddr = response.ServerAddr || "unknown";
|
||||
const hostname = serverAddr.split(":")[0];
|
||||
const certBytes = response.ServerCertChain[0];
|
||||
|
||||
let certInfo: CertificateInfo;
|
||||
|
||||
if (response.CertificateInfo) {
|
||||
certInfo = response.CertificateInfo;
|
||||
// Add missing fingerprint and keySize that the server doesn't provide
|
||||
if (!certInfo.fingerprint) {
|
||||
certInfo.fingerprint = await calculateFingerprint(certBytes);
|
||||
}
|
||||
if (!certInfo.keySize && certBytes.length > 100) {
|
||||
if (certBytes.length > 1400) {
|
||||
certInfo.keySize = 2048;
|
||||
} else if (certBytes.length > 1000) {
|
||||
certInfo.keySize = 1024;
|
||||
} else if (certBytes.length > 600) {
|
||||
certInfo.keySize = 1024;
|
||||
} else {
|
||||
certInfo.keySize = 512;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
certInfo = await parseCertificate(certBytes, hostname);
|
||||
} catch (error) {
|
||||
console.error("Certificate parsing error:", error);
|
||||
const fingerprint = await calculateFingerprint(certBytes);
|
||||
certInfo = {
|
||||
raw: certBytes,
|
||||
fingerprint,
|
||||
hostname,
|
||||
subject: "Unable to parse",
|
||||
issuer: "Unable to parse",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return await validateCertificate(certInfo, hostname);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
},
|
||||
[parseCertificate, validateCertificate, calculateFingerprint],
|
||||
);
|
||||
|
||||
const acceptCertificate = useCallback(
|
||||
(
|
||||
hostname: string,
|
||||
certInfo: CertificateInfo,
|
||||
remember: boolean = true,
|
||||
): void => {
|
||||
if (remember) {
|
||||
saveTrustedCert(hostname, certInfo);
|
||||
}
|
||||
},
|
||||
[saveTrustedCert],
|
||||
);
|
||||
|
||||
const clearTrustedCerts = useCallback((): void => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
const listTrustedCerts = useCallback((): TrustedCertificate[] => {
|
||||
return Object.values(getTrustedCerts());
|
||||
}, [getTrustedCerts]);
|
||||
|
||||
return {
|
||||
isValidating,
|
||||
handleRDCleanPathResponse,
|
||||
validateCertificate,
|
||||
acceptCertificate,
|
||||
clearTrustedCerts,
|
||||
getTrustedCerts: listTrustedCerts,
|
||||
calculateFingerprint,
|
||||
parseCertificate,
|
||||
};
|
||||
};
|
||||
58
src/modules/remote-access/rdp/useRDPQueryParams.ts
Normal file
58
src/modules/remote-access/rdp/useRDPQueryParams.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface RDPQueryParams {
|
||||
peerId: string | null;
|
||||
}
|
||||
|
||||
export function useRDPQueryParams() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [params, setParams] = useState<RDPQueryParams>({
|
||||
peerId: null,
|
||||
});
|
||||
const [, setLocalQueryParams] = useLocalStorage("netbird-query-params", "");
|
||||
|
||||
useEffect(() => {
|
||||
const peerId = searchParams.get("id");
|
||||
|
||||
// If all params are present in URL, use them
|
||||
if (peerId) {
|
||||
setParams({ peerId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, try to restore from localStorage
|
||||
const storedParams = localStorage.getItem("netbird-query-params");
|
||||
if (!storedParams) return;
|
||||
|
||||
// Handle JSON encoded strings from localStorage
|
||||
let paramsString = storedParams;
|
||||
if (storedParams.startsWith('"') && storedParams.endsWith('"')) {
|
||||
try {
|
||||
paramsString = JSON.parse(storedParams);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(paramsString);
|
||||
const storedPeerId = urlParams.get("id");
|
||||
|
||||
if (storedPeerId) {
|
||||
const newSearchParams = new URLSearchParams();
|
||||
newSearchParams.set("id", storedPeerId);
|
||||
|
||||
router.replace(`/peer/rdp?${newSearchParams.toString()}`);
|
||||
setParams({
|
||||
peerId: storedPeerId,
|
||||
});
|
||||
|
||||
// Clear stored params after restoring
|
||||
setLocalQueryParams("");
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
return params;
|
||||
}
|
||||
325
src/modules/remote-access/rdp/useRemoteDesktop.ts
Normal file
325
src/modules/remote-access/rdp/useRemoteDesktop.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CertificatePromptInfo,
|
||||
useRDPCertificateHandler,
|
||||
} from "./useRDPCertificateHandler";
|
||||
|
||||
interface IronError {
|
||||
message: string;
|
||||
backtrace?: () => string;
|
||||
}
|
||||
|
||||
interface RDPConfig {
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface RDPCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface RDPConnection {
|
||||
id: string;
|
||||
disconnect: (options?: {
|
||||
preserveConfig?: boolean;
|
||||
preserveCertificateState?: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export enum RDPStatus {
|
||||
DISCONNECTED = 0,
|
||||
CONNECTED = 1,
|
||||
CONNECTING = 2,
|
||||
}
|
||||
|
||||
export const RDP_DOCS_LINK = "https://docs.netbird.io/";
|
||||
|
||||
export const useRemoteDesktop = (client: any) => {
|
||||
const [status, setStatus] = useState(RDPStatus.DISCONNECTED);
|
||||
const [config, setConfig] = useState<RDPConfig | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [pendingCertificate, setPendingCertificate] =
|
||||
useState<CertificatePromptInfo | null>(null);
|
||||
|
||||
const session = useRef<RDPConnection | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastConnectedConfigRef = useRef<RDPConfig | null>(null);
|
||||
const certificatePromiseRef = useRef<{
|
||||
resolve: (value: boolean) => void;
|
||||
reject: (reason?: any) => void;
|
||||
} | null>(null);
|
||||
|
||||
const { handleRDCleanPathResponse, acceptCertificate } =
|
||||
useRDPCertificateHandler();
|
||||
const certificateAccepted = useRef(false);
|
||||
|
||||
/**
|
||||
* Reset the RDP state, optionally preserving config and/or certificate state
|
||||
*/
|
||||
const resetState = useCallback(
|
||||
(
|
||||
options: {
|
||||
preserveConfig?: boolean;
|
||||
preserveCertificateState?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
session.current = null;
|
||||
setStatus(RDPStatus.DISCONNECTED);
|
||||
if (!options.preserveConfig) {
|
||||
setConfig(null);
|
||||
}
|
||||
setError("");
|
||||
if (!options.preserveCertificateState) {
|
||||
setPendingCertificate(null);
|
||||
certificatePromiseRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Set up the global RDPCertificateHandler to intercept certificate prompts
|
||||
*/
|
||||
const setupCertificateHandler = useCallback(() => {
|
||||
const originalHandler = (window as any).RDPCertificateHandler;
|
||||
|
||||
(window as any).RDPCertificateHandler = function () {
|
||||
this.handleRDCleanPathResponse = async (response: any) => {
|
||||
const result = await handleRDCleanPathResponse(response);
|
||||
|
||||
if (result.isValid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result.needsUserConfirmation && result.promptInfo) {
|
||||
setPendingCertificate(result.promptInfo);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
certificatePromiseRef.current = { resolve, reject };
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (originalHandler?.prototype) {
|
||||
Object.getOwnPropertyNames(originalHandler.prototype).forEach(
|
||||
(name) => {
|
||||
if (
|
||||
name !== "constructor" &&
|
||||
name !== "handleRDCleanPathResponse"
|
||||
) {
|
||||
this[name] = originalHandler.prototype[name];
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return originalHandler;
|
||||
}, [handleRDCleanPathResponse]);
|
||||
|
||||
/**
|
||||
* Establish an RDP connection
|
||||
*/
|
||||
const connect = useCallback(
|
||||
async (rdpConfig: RDPConfig): Promise<RDPStatus> => {
|
||||
if (status === RDPStatus.CONNECTING) return status;
|
||||
|
||||
setStatus(RDPStatus.CONNECTING);
|
||||
setConfig(rdpConfig);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
if (!canvasRef.current) {
|
||||
throw new Error("Canvas not available for RDP rendering");
|
||||
}
|
||||
|
||||
if (!client?.ironRDPBridge || !client?.initializeIronRDP) {
|
||||
throw new Error("IronRDP components not available from client");
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
canvas.width = rdpConfig.width || 1024;
|
||||
canvas.height = rdpConfig.height || 768;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
}
|
||||
|
||||
const initialized = await client.initializeIronRDP();
|
||||
if (!initialized) {
|
||||
throw new Error("Failed to initialize IronRDP");
|
||||
}
|
||||
|
||||
const originalHandler = setupCertificateHandler();
|
||||
|
||||
try {
|
||||
const sessionId = await client.ironRDPBridge.connect(
|
||||
rdpConfig.hostname,
|
||||
rdpConfig.port,
|
||||
rdpConfig.username,
|
||||
rdpConfig.password,
|
||||
canvas,
|
||||
true,
|
||||
client.client,
|
||||
);
|
||||
|
||||
session.current = {
|
||||
id: sessionId,
|
||||
disconnect: (options = {}) => {
|
||||
try {
|
||||
if (client.ironRDPBridge && sessionId) {
|
||||
client.ironRDPBridge.disconnect(sessionId);
|
||||
}
|
||||
resetState(options);
|
||||
} catch (err) {
|
||||
resetState(options);
|
||||
}
|
||||
},
|
||||
};
|
||||
setStatus(RDPStatus.CONNECTED);
|
||||
lastConnectedConfigRef.current = rdpConfig;
|
||||
return RDPStatus.CONNECTED;
|
||||
} catch (err) {
|
||||
const ironError = err as IronError;
|
||||
const errorMessage = ironError.backtrace
|
||||
? ironError.backtrace()
|
||||
: "RDP connection failed";
|
||||
setError(errorMessage);
|
||||
resetState();
|
||||
throw Error(errorMessage);
|
||||
} finally {
|
||||
(window as any).RDPCertificateHandler = originalHandler;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "RDP connection failed";
|
||||
setError(errorMessage);
|
||||
resetState();
|
||||
throw Error(errorMessage);
|
||||
}
|
||||
},
|
||||
[client, status, setupCertificateHandler, resetState],
|
||||
);
|
||||
|
||||
/**
|
||||
* Accept the pending certificate prompt
|
||||
*/
|
||||
const acceptCertificatePrompt = useCallback(
|
||||
(remember: boolean = false) => {
|
||||
if (!pendingCertificate || !certificatePromiseRef.current) return;
|
||||
|
||||
acceptCertificate(
|
||||
pendingCertificate.hostname,
|
||||
pendingCertificate.certificate,
|
||||
remember,
|
||||
);
|
||||
|
||||
certificatePromiseRef.current.resolve(true);
|
||||
setPendingCertificate(null);
|
||||
certificatePromiseRef.current = null;
|
||||
certificateAccepted.current = true;
|
||||
},
|
||||
[pendingCertificate, acceptCertificate],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reject the pending certificate prompt
|
||||
*/
|
||||
const rejectCertificatePrompt = useCallback(() => {
|
||||
if (!certificatePromiseRef.current) return;
|
||||
certificatePromiseRef.current.resolve(false);
|
||||
setPendingCertificate(null);
|
||||
certificatePromiseRef.current = null;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle window resize events - reconnect with new dimensions
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
// Only handle resize if we're connected and have a previous config
|
||||
if (status !== RDPStatus.CONNECTED || !lastConnectedConfigRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
|
||||
// Debounce resize handling for 1 second
|
||||
resizeTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
// Disconnect current session
|
||||
if (session.current) {
|
||||
session.current.disconnect({
|
||||
preserveConfig: true,
|
||||
preserveCertificateState: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for cleanup
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Reconnect with new dimensions
|
||||
const newConfig = {
|
||||
...lastConnectedConfigRef.current!,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
|
||||
await connect(newConfig);
|
||||
} finally {
|
||||
setIsResizing(false);
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [status, connect]);
|
||||
|
||||
/**
|
||||
* Auto accept certificate if previously accepted (for reconnects)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (pendingCertificate && certificateAccepted.current) {
|
||||
acceptCertificatePrompt();
|
||||
}
|
||||
}, [acceptCertificatePrompt, pendingCertificate]);
|
||||
|
||||
return {
|
||||
connect,
|
||||
status,
|
||||
config,
|
||||
error,
|
||||
isResizing,
|
||||
session: session.current,
|
||||
canvasRef,
|
||||
|
||||
// Certificate handling
|
||||
pendingCertificate,
|
||||
acceptCertificatePrompt,
|
||||
rejectCertificatePrompt,
|
||||
};
|
||||
};
|
||||
206
src/modules/remote-access/rdp/websocket-proxy.ts
Normal file
206
src/modules/remote-access/rdp/websocket-proxy.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* WebSocket Proxy System for RDCleanPath connections
|
||||
* Provides WebSocket interface that routes through RDCleanPath proxy
|
||||
*/
|
||||
|
||||
import type { CertificateHandler, CertificateInfo, RDCleanPathResponse } from './rdp-certificate-handler';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
handleRDCleanPathWebSocket?: (ws: RDCleanPathProxyWebSocket, proxyID: string) => void;
|
||||
createRDCleanPathProxy?: (hostname: string, port: number) => Promise<string>;
|
||||
getRDCleanPathCertificate?: (proxyID: string) => Promise<RDCleanPathResponse | null>;
|
||||
RDCleanPathProxyWebSocket?: typeof RDCleanPathProxyWebSocket;
|
||||
sendToRDCleanPathProxy?: (proxyID: string, data: ArrayBuffer | Uint8Array | string) => void;
|
||||
closeRDCleanPathProxy?: (proxyID: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseWebSocketProxy extends EventTarget {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
url: string;
|
||||
readyState: number;
|
||||
readonly protocol: string = '';
|
||||
readonly extensions: string = '';
|
||||
readonly bufferedAmount: number = 0;
|
||||
readonly binaryType: BinaryType = 'blob';
|
||||
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
protected messageQueue: any[] = [];
|
||||
|
||||
constructor(url: string) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.readyState = BaseWebSocketProxy.CONNECTING;
|
||||
}
|
||||
|
||||
get CONNECTING() { return BaseWebSocketProxy.CONNECTING; }
|
||||
get OPEN() { return BaseWebSocketProxy.OPEN; }
|
||||
get CLOSING() { return BaseWebSocketProxy.CLOSING; }
|
||||
get CLOSED() { return BaseWebSocketProxy.CLOSED; }
|
||||
|
||||
protected emitOpen(): void {
|
||||
this.readyState = BaseWebSocketProxy.OPEN;
|
||||
const event = new Event('open');
|
||||
this.onopen?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
protected emitError(error: any): void {
|
||||
const event = new Event('error');
|
||||
this.onerror?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
protected emitMessage(data: any): void {
|
||||
const event = new MessageEvent('message', { data });
|
||||
this.onmessage?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
protected emitClose(code = 1000, reason = ''): void {
|
||||
this.readyState = BaseWebSocketProxy.CLOSED;
|
||||
const event = new CloseEvent('close', { code, reason, wasClean: code === 1000 });
|
||||
this.onclose?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
abstract send(data: ArrayBuffer | Uint8Array | string | Blob | ArrayBufferView): void;
|
||||
abstract close(code?: number, reason?: string): void;
|
||||
}
|
||||
|
||||
export class RDCleanPathProxyWebSocket extends BaseWebSocketProxy {
|
||||
private proxyID: string;
|
||||
private certificateHandler: CertificateHandler | null;
|
||||
onGoMessage?: (data: Uint8Array) => void;
|
||||
onGoClose?: () => void;
|
||||
onCertificateRequest?: (certData: RDCleanPathResponse) => Promise<boolean>;
|
||||
|
||||
constructor(url: string) {
|
||||
super(url);
|
||||
const match = url.match(/rdcleanpath\.proxy\.local\/(.+)/);
|
||||
this.proxyID = match?.[1] || 'default';
|
||||
|
||||
if (window.RDPCertificateHandler) {
|
||||
this.certificateHandler = new window.RDPCertificateHandler();
|
||||
} else {
|
||||
this.certificateHandler = null;
|
||||
}
|
||||
this.onCertificateRequest = async (certData) => {
|
||||
return this.validateCertificate(certData);
|
||||
};
|
||||
void this._connect();
|
||||
}
|
||||
|
||||
private async _connect(): Promise<void> {
|
||||
try {
|
||||
const handler = (window as any)[`handleRDCleanPathWebSocket_${this.proxyID}`];
|
||||
if (!handler) {
|
||||
throw new Error(`RDCleanPath WebSocket handler not available for proxy ${this.proxyID}`);
|
||||
}
|
||||
handler(this);
|
||||
this.emitOpen();
|
||||
} catch (error) {
|
||||
console.error('RDCleanPath WebSocket connection failed:', error);
|
||||
this.emitError(error);
|
||||
this.emitClose(1006, error instanceof Error ? error.message : 'Connection failed');
|
||||
}
|
||||
}
|
||||
|
||||
protected _sendInternal(data: Uint8Array): void {
|
||||
if (this.onGoMessage) {
|
||||
this.onGoMessage(data);
|
||||
} else {
|
||||
console.warn('onGoMessage not set for proxy', this.proxyID);
|
||||
}
|
||||
}
|
||||
|
||||
send(data: ArrayBuffer | Uint8Array | string | Blob | ArrayBufferView): void {
|
||||
if (this.readyState === BaseWebSocketProxy.CONNECTING) {
|
||||
this.messageQueue.push(data);
|
||||
return;
|
||||
}
|
||||
if (this.readyState !== BaseWebSocketProxy.OPEN) {
|
||||
throw new Error('WebSocket is not open');
|
||||
}
|
||||
// Convert all data types to Uint8Array
|
||||
if (data instanceof Blob) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this._sendInternal(new Uint8Array(reader.result as ArrayBuffer));
|
||||
};
|
||||
reader.readAsArrayBuffer(data);
|
||||
} else if (typeof data === 'string') {
|
||||
const encoder = new TextEncoder();
|
||||
this._sendInternal(encoder.encode(data));
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
this._sendInternal(new Uint8Array(data));
|
||||
} else if ('buffer' in data && data.buffer instanceof ArrayBuffer) {
|
||||
const view = data as ArrayBufferView;
|
||||
this._sendInternal(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
||||
} else {
|
||||
this._sendInternal(data as Uint8Array);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateCertificate(certData: RDCleanPathResponse): Promise<boolean> {
|
||||
if (!this.certificateHandler) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await this.certificateHandler.handleRDCleanPathResponse(certData);
|
||||
} catch (error) {
|
||||
console.error('Certificate validation error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Called from Go side to pass data to JavaScript
|
||||
receiveFromGo(data: ArrayBuffer): void {
|
||||
this.emitMessage(data);
|
||||
}
|
||||
|
||||
// Called from Go side to close the connection
|
||||
closeFromGo(code?: number, reason?: string): void {
|
||||
this.emitClose(code, reason);
|
||||
}
|
||||
|
||||
close(code = 1000, reason = ''): void {
|
||||
if (this.readyState === BaseWebSocketProxy.CLOSING || this.readyState === BaseWebSocketProxy.CLOSED) {
|
||||
return;
|
||||
}
|
||||
this.readyState = BaseWebSocketProxy.CLOSING;
|
||||
if (this.onGoClose) {
|
||||
this.onGoClose();
|
||||
} else if (window.closeRDCleanPathProxy) {
|
||||
window.closeRDCleanPathProxy(this.proxyID);
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.emitClose(code, reason);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function installWebSocketProxy(): void {
|
||||
const OriginalWebSocket = window.WebSocket;
|
||||
window.WebSocket = new Proxy(OriginalWebSocket, {
|
||||
construct(_target, args) {
|
||||
const url = args[0] as string;
|
||||
|
||||
if (url?.includes('rdcleanpath.proxy.local')) {
|
||||
window.RDCleanPathProxyWebSocket = RDCleanPathProxyWebSocket;
|
||||
return new RDCleanPathProxyWebSocket(url) as unknown as WebSocket;
|
||||
}
|
||||
|
||||
return new OriginalWebSocket(url, args[1]);
|
||||
}
|
||||
}) as typeof WebSocket;
|
||||
}
|
||||
76
src/modules/remote-access/ssh/SSHButton.tsx
Normal file
76
src/modules/remote-access/ssh/SSHButton.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import Button from "@components/Button";
|
||||
import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { CircleHelpIcon, TerminalIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
|
||||
import { SSHTooltip } from "@/modules/remote-access/ssh/SSHTooltip";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
isDropdown?: boolean;
|
||||
};
|
||||
|
||||
export const SSHButton = ({ peer, isDropdown = false }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const disabled =
|
||||
!peer.connected || !peer.ssh_enabled || !permission.peers.update;
|
||||
|
||||
const hasPermission = permission.peers.update;
|
||||
|
||||
const os = getOperatingSystem(peer?.os);
|
||||
const isWindows = os === OperatingSystem.WINDOWS;
|
||||
const isMobile = os === OperatingSystem.ANDROID || os === OperatingSystem.IOS;
|
||||
const isSSHSupported = !isWindows && !isMobile;
|
||||
|
||||
return (
|
||||
isSSHSupported && (
|
||||
<>
|
||||
{modal && (
|
||||
<SSHCredentialsModal
|
||||
open={modal}
|
||||
onOpenChange={setModal}
|
||||
peer={peer}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<SSHTooltip
|
||||
disabled={!disabled}
|
||||
hasPermission={hasPermission}
|
||||
side={isDropdown ? "left" : "top"}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setModal(true)}
|
||||
disabled={disabled}
|
||||
className={"w-full"}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<TerminalIcon size={14} className={"shrink-0"} />
|
||||
SSH
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setModal(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<TerminalIcon size={16} />
|
||||
SSH
|
||||
{disabled && <CircleHelpIcon size={12} />}
|
||||
</Button>
|
||||
)}
|
||||
</SSHTooltip>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
155
src/modules/remote-access/ssh/SSHCredentialsModal.tsx
Normal file
155
src/modules/remote-access/ssh/SSHCredentialsModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
} from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
ChevronsLeftRightEllipsis,
|
||||
ExternalLinkIcon,
|
||||
TerminalIcon,
|
||||
User2,
|
||||
} from "lucide-react";
|
||||
import Separator from "@components/Separator";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Button from "@components/Button";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { isNativeSSHSupported } from "@utils/version";
|
||||
import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
peer: Peer;
|
||||
};
|
||||
|
||||
export const SSHCredentialsModal = ({ open, onOpenChange, peer }: Props) => {
|
||||
const [username, setUsername] = useState(
|
||||
getOperatingSystem(peer.os) === OperatingSystem.WINDOWS
|
||||
? "Administrator"
|
||||
: "root",
|
||||
);
|
||||
|
||||
const [port, setPort] = useState(
|
||||
isNativeSSHSupported(peer.version) ? "22" : "44338",
|
||||
);
|
||||
|
||||
const userNameError = useMemo(() => {
|
||||
if (username?.length === 0) return "Username cannot be empty";
|
||||
}, [username]);
|
||||
|
||||
const portError = useMemo(() => {
|
||||
const portNumber = Number(port);
|
||||
const isValid =
|
||||
Number.isInteger(portNumber) && portNumber > 0 && portNumber <= 65535;
|
||||
if (!isValid) return "Port must be a number between 1 and 65535";
|
||||
}, [port]);
|
||||
|
||||
const hasAnyError = useMemo(() => {
|
||||
if (userNameError !== undefined) return true;
|
||||
return portError !== undefined;
|
||||
}, [userNameError, portError]);
|
||||
|
||||
const openSSHWindow = () => {
|
||||
const encodedUsername = encodeURIComponent(username.trim());
|
||||
const encodedPort = encodeURIComponent(port.trim());
|
||||
|
||||
window.open(
|
||||
`peer/ssh?id=${peer.id}&user=${encodedUsername}&port=${encodedPort}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=800,height=450,left=100,top=100,location=no,toolbar=no,menubar=no,status=no",
|
||||
);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent maxWidthClass={"max-w-lg"}>
|
||||
<ModalHeader
|
||||
icon={<TerminalIcon className={"text-netbird"} size={18} />}
|
||||
title={peer.name}
|
||||
description={`Connect to ${peer.ip} via SSH`}
|
||||
color={"netbird"}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-8"}>
|
||||
<div className={""}>
|
||||
<Label>Username & Port</Label>
|
||||
<HelpText>
|
||||
The username and port you will use to connect to the remote host.
|
||||
</HelpText>
|
||||
<div className={"flex flex-col gap-2 w-full"}>
|
||||
<Input
|
||||
placeholder={"root"}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
customSuffix={`@${peer.ip}`}
|
||||
data-1p-ignore
|
||||
autoComplete={"off"}
|
||||
error={userNameError}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
customPrefix={
|
||||
<User2 size={16} className={"text-nb-gray-300"} />
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
maxWidthClass={""}
|
||||
placeholder={"22"}
|
||||
min={1}
|
||||
max={65535}
|
||||
value={port}
|
||||
type={"number"}
|
||||
error={portError}
|
||||
errorTooltip={true}
|
||||
errorTooltipPosition={"top-right"}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
customPrefix={
|
||||
<ChevronsLeftRightEllipsis
|
||||
size={16}
|
||||
className={"text-nb-gray-300"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
<InlineLink href={SSH_DOCS_LINK} target={"_blank"}>
|
||||
SSH
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={hasAnyError}
|
||||
onClick={openSSHWindow}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
55
src/modules/remote-access/ssh/SSHTooltip.tsx
Normal file
55
src/modules/remote-access/ssh/SSHTooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { SSH_DOCS_LINK } from "@/modules/remote-access/ssh/useSSH";
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
hasPermission?: boolean;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
};
|
||||
export const SSHTooltip = ({
|
||||
disabled,
|
||||
children,
|
||||
hasPermission,
|
||||
side = "top",
|
||||
}: Props) => {
|
||||
return (
|
||||
<FullTooltip
|
||||
className={"w-full"}
|
||||
side={side}
|
||||
content={
|
||||
<div className={"max-w-xs text-xs flex flex-col gap-2"}>
|
||||
{hasPermission ? (
|
||||
<>
|
||||
<div>
|
||||
This peer is either offline or SSH access is not enabled.
|
||||
</div>
|
||||
<div>
|
||||
Please enable SSH access for this peer in the dashboard and make
|
||||
sure SSH is allowed in the NetBird Client under{" "}
|
||||
<span className={"text-white"}>Settings → Allow SSH</span>.
|
||||
</div>
|
||||
<div>
|
||||
Learn more about{" "}
|
||||
<InlineLink href={SSH_DOCS_LINK} target={"_blank"}>
|
||||
SSH <ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
You do not have permission to launch the SSH console. Please
|
||||
contact your administrator.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</FullTooltip>
|
||||
);
|
||||
};
|
||||
172
src/modules/remote-access/ssh/Terminal.tsx
Normal file
172
src/modules/remote-access/ssh/Terminal.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
const TERMINAL_OPTIONS = {
|
||||
theme: {
|
||||
background: "#181a1d",
|
||||
foreground: "#e4e7e9",
|
||||
cursor: "#e4e7e9",
|
||||
},
|
||||
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
scrollback: 1000,
|
||||
allowTransparency: true,
|
||||
};
|
||||
|
||||
interface TerminalWithCore {
|
||||
_core?: { _isDisposed: boolean };
|
||||
dispose(): void;
|
||||
write(data: string | Uint8Array): void;
|
||||
writeln(data: string): void;
|
||||
onData(callback: (data: string) => void): void;
|
||||
onResize(callback: (event: { cols: number; rows: number }) => void): void;
|
||||
focus(): void;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface SSHTerminalWrapperProps {
|
||||
session: any;
|
||||
onResize?: (cols: number, rows: number) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Terminal = ({
|
||||
session,
|
||||
onResize,
|
||||
onClose,
|
||||
className = "",
|
||||
}: SSHTerminalWrapperProps) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminalInstanceRef = useRef<{
|
||||
terminal: TerminalWithCore;
|
||||
fitAddon: any;
|
||||
} | null>(null);
|
||||
const handlersSetRef = useRef(false);
|
||||
|
||||
const fitTerminal = useCallback(() => {
|
||||
if (terminalInstanceRef.current?.fitAddon) {
|
||||
terminalInstanceRef.current.fitAddon.fit();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const initializeTerminal = useCallback(async () => {
|
||||
if (terminalInstanceRef.current || !terminalRef.current) return;
|
||||
|
||||
const { Terminal: XTerminal } = await import("@xterm/xterm");
|
||||
const { FitAddon } = await import("@xterm/addon-fit");
|
||||
|
||||
const terminal = new XTerminal(TERMINAL_OPTIONS);
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
if (!terminalRef.current) return;
|
||||
terminalRef.current.innerHTML = "";
|
||||
terminal.open(terminalRef.current);
|
||||
|
||||
// Set terminal focus behavior
|
||||
const terminalElement = terminalRef.current.querySelector(
|
||||
".xterm",
|
||||
) as HTMLElement;
|
||||
if (terminalElement) {
|
||||
terminalElement.setAttribute("tabindex", "0");
|
||||
terminalElement.addEventListener("click", () => terminal.focus());
|
||||
terminalElement.addEventListener("keydown", (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
terminalInstanceRef.current = {
|
||||
terminal: terminal as TerminalWithCore,
|
||||
fitAddon,
|
||||
};
|
||||
|
||||
// Initial fit with delay to ensure proper sizing
|
||||
setTimeout(fitTerminal, 100);
|
||||
|
||||
return terminal as TerminalWithCore;
|
||||
}, [fitTerminal]);
|
||||
|
||||
const setupSSHHandlers = useCallback(async () => {
|
||||
if (!session || handlersSetRef.current) return;
|
||||
|
||||
const terminal = await initializeTerminal();
|
||||
if (!terminal) return;
|
||||
|
||||
handlersSetRef.current = true;
|
||||
|
||||
// Setup terminal event handlers
|
||||
terminal.onData((data: string) => session?.write?.(data));
|
||||
terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => {
|
||||
session?.resize?.(cols, rows);
|
||||
onResize?.(cols, rows);
|
||||
});
|
||||
|
||||
// Setup SSH event handlers
|
||||
session.ondata = (data: Uint8Array) => {
|
||||
if (!terminal._core?._isDisposed) {
|
||||
terminal.write(new Uint8Array(data));
|
||||
}
|
||||
};
|
||||
|
||||
const originalOnClose = session.onclose;
|
||||
session.onclose = () => {
|
||||
if (!terminal._core?._isDisposed) {
|
||||
terminal.writeln("\r\n*** Connection closed ***");
|
||||
}
|
||||
handlersSetRef.current = false;
|
||||
originalOnClose?.();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
// Final setup with proper sizing
|
||||
setTimeout(() => {
|
||||
if (
|
||||
terminalInstanceRef.current?.fitAddon &&
|
||||
!terminal._core?._isDisposed
|
||||
) {
|
||||
fitTerminal();
|
||||
session?.resize?.(terminal.cols, terminal.rows);
|
||||
terminal.focus();
|
||||
}
|
||||
}, 200);
|
||||
}, [session, initializeTerminal, onResize, onClose, fitTerminal]);
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => fitTerminal();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [fitTerminal]);
|
||||
|
||||
// Setup SSH handlers when session changes
|
||||
useEffect(() => {
|
||||
setupSSHHandlers().then();
|
||||
}, [setupSSHHandlers]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (terminalInstanceRef.current?.terminal) {
|
||||
const terminal = terminalInstanceRef.current.terminal;
|
||||
if (!terminal._core?._isDisposed) {
|
||||
terminal.dispose();
|
||||
}
|
||||
terminalInstanceRef.current = null;
|
||||
}
|
||||
handlersSetRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className={cn("w-full h-full flex flex-col m-0 p-0", className)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
85
src/modules/remote-access/ssh/useSSH.ts
Normal file
85
src/modules/remote-access/ssh/useSSH.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
interface SSHConfig {
|
||||
hostname: string;
|
||||
port: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface SSHConnection {
|
||||
write: (data: string) => void;
|
||||
resize: (cols: number, rows: number) => void;
|
||||
close: () => void;
|
||||
ondata: ((data: Uint8Array) => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
}
|
||||
|
||||
export enum SSHStatus {
|
||||
DISCONNECTED = 0,
|
||||
CONNECTED = 1,
|
||||
CONNECTING = 2,
|
||||
}
|
||||
|
||||
export const SSH_DOCS_LINK = "https://docs.netbird.io/";
|
||||
|
||||
export const useSSH = (client: any) => {
|
||||
const [status, setStatus] = useState(SSHStatus.DISCONNECTED);
|
||||
const [config, setConfig] = useState<SSHConfig | null>(null);
|
||||
const session = useRef<SSHConnection | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const connect = useCallback(
|
||||
async (config: SSHConfig): Promise<SSHStatus> => {
|
||||
if (status === SSHStatus.CONNECTED || status === SSHStatus.CONNECTING)
|
||||
return status;
|
||||
|
||||
setStatus(SSHStatus.CONNECTING);
|
||||
setConfig(config);
|
||||
|
||||
try {
|
||||
const ssh = await client.createSSHConnection(
|
||||
config.hostname,
|
||||
config.port,
|
||||
config.username,
|
||||
);
|
||||
|
||||
ssh.onclose = () => {
|
||||
setStatus(SSHStatus.DISCONNECTED);
|
||||
setConfig(null);
|
||||
session.current = null;
|
||||
};
|
||||
|
||||
session.current = ssh;
|
||||
|
||||
setStatus(SSHStatus.CONNECTED);
|
||||
return SSHStatus.CONNECTED;
|
||||
} catch (err) {
|
||||
console.error("SSH connection failed:", err);
|
||||
session.current = null;
|
||||
setStatus(SSHStatus.DISCONNECTED);
|
||||
setError("SSH connection failed. Check the console for details.");
|
||||
setConfig(null);
|
||||
return SSHStatus.DISCONNECTED;
|
||||
}
|
||||
},
|
||||
[client, status],
|
||||
);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (session.current) {
|
||||
session.current.close();
|
||||
session.current = null;
|
||||
setStatus(SSHStatus.DISCONNECTED);
|
||||
setConfig(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
connect,
|
||||
error,
|
||||
status,
|
||||
config,
|
||||
session: session.current,
|
||||
disconnect,
|
||||
};
|
||||
};
|
||||
70
src/modules/remote-access/ssh/useSSHQueryParams.ts
Normal file
70
src/modules/remote-access/ssh/useSSHQueryParams.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useLocalStorage } from "@hooks/useLocalStorage";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface SSHQueryParams {
|
||||
peerId: string | null;
|
||||
username: string | null;
|
||||
port: string | null;
|
||||
}
|
||||
|
||||
export function useSSHQueryParams() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [params, setParams] = useState<SSHQueryParams>({
|
||||
peerId: null,
|
||||
username: null,
|
||||
port: null,
|
||||
});
|
||||
const [, setLocalQueryParams] = useLocalStorage("netbird-query-params", "");
|
||||
|
||||
useEffect(() => {
|
||||
const peerId = searchParams.get("id");
|
||||
const username = searchParams.get("user");
|
||||
const port = searchParams.get("port");
|
||||
|
||||
// If all params are present in URL, use them
|
||||
if (peerId && username && port) {
|
||||
setParams({ peerId, username, port });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, try to restore from localStorage
|
||||
const storedParams = localStorage.getItem("netbird-query-params");
|
||||
if (!storedParams) return;
|
||||
|
||||
// Handle JSON encoded strings from localStorage
|
||||
let paramsString = storedParams;
|
||||
if (storedParams.startsWith('"') && storedParams.endsWith('"')) {
|
||||
try {
|
||||
paramsString = JSON.parse(storedParams);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const urlParams = new URLSearchParams(paramsString);
|
||||
const storedPeerId = urlParams.get("id");
|
||||
const storedUsername = urlParams.get("user");
|
||||
const storedPort = urlParams.get("port");
|
||||
|
||||
if (storedPeerId && storedUsername && storedPort) {
|
||||
const newSearchParams = new URLSearchParams();
|
||||
newSearchParams.set("id", storedPeerId);
|
||||
newSearchParams.set("user", storedUsername);
|
||||
newSearchParams.set("port", storedPort);
|
||||
|
||||
router.replace(`/peer/ssh?${newSearchParams.toString()}`);
|
||||
setParams({
|
||||
peerId: storedPeerId,
|
||||
username: storedUsername,
|
||||
port: storedPort,
|
||||
});
|
||||
|
||||
// Clear stored params after restoring
|
||||
setLocalQueryParams("");
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
return params;
|
||||
}
|
||||
306
src/modules/remote-access/useNetBirdClient.ts
Normal file
306
src/modules/remote-access/useNetBirdClient.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import loadConfig from "@utils/config";
|
||||
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { IronRDPInputHandler } from "@/modules/remote-access/rdp/ironrdp-input-handler";
|
||||
import { IronRDPWASMBridge } from "@/modules/remote-access/rdp/ironrdp-wasm-bridge";
|
||||
import { RDPCertificateHandler } from "@/modules/remote-access/rdp/rdp-certificate-handler";
|
||||
import { installWebSocketProxy } from "@/modules/remote-access/rdp/websocket-proxy";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { generateKeypair } from "@utils/wireguard";
|
||||
import { getBrowserInfo } from "@utils/helpers";
|
||||
import { trim } from "lodash";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const WASM_CONFIG = {
|
||||
SCRIPT_PATH: "/wasm_exec.js",
|
||||
WASM_PATH: "/netbird.wasm",
|
||||
INIT_TIMEOUT: 10000,
|
||||
RETRY_DELAY: 100,
|
||||
} as const;
|
||||
|
||||
export enum NetBirdStatus {
|
||||
DISCONNECTED = 0,
|
||||
CONNECTED = 1,
|
||||
CONNECTING = 2,
|
||||
}
|
||||
|
||||
export enum WASMStatus {
|
||||
UNINITIALIZED,
|
||||
INITIALIZED,
|
||||
INITIALIZING,
|
||||
}
|
||||
|
||||
type NetBirdState = {
|
||||
status: NetBirdStatus;
|
||||
wasmStatus: WASMStatus;
|
||||
error: string;
|
||||
};
|
||||
|
||||
class NetBirdStore {
|
||||
private state: NetBirdState = {
|
||||
status: NetBirdStatus.DISCONNECTED,
|
||||
wasmStatus: WASMStatus.UNINITIALIZED,
|
||||
error: "",
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
getState = (): NetBirdState => this.state;
|
||||
|
||||
setState = (newState: Partial<NetBirdState>): void => {
|
||||
this.state = { ...this.state, ...newState };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const netBirdStore = new NetBirdStore();
|
||||
|
||||
export const useNetBirdClient = () => {
|
||||
const netBirdClient = useRef<any>(null);
|
||||
const state = useSyncExternalStore(
|
||||
netBirdStore.subscribe,
|
||||
netBirdStore.getState,
|
||||
netBirdStore.getState,
|
||||
);
|
||||
const { status, wasmStatus, error } = state;
|
||||
const peerRequest = useApiCall(`/peers`);
|
||||
|
||||
const rdpComponents = useRef<{
|
||||
bridge: IronRDPWASMBridge | null;
|
||||
inputHandler: typeof IronRDPInputHandler | null;
|
||||
certificateHandler: typeof RDPCertificateHandler | null;
|
||||
}>({ bridge: null, inputHandler: null, certificateHandler: null });
|
||||
|
||||
const loadWASMRuntime = useCallback((): Promise<void> => {
|
||||
if (document.querySelector(`script[src="${WASM_CONFIG.SCRIPT_PATH}"]`)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = WASM_CONFIG.SCRIPT_PATH;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error("Failed to load WASM runtime"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadGoClient = useCallback(async (): Promise<void> => {
|
||||
if ((window as any).NetBirdClient) return;
|
||||
|
||||
const go = new (window as any).Go();
|
||||
const wasmModule = await WebAssembly.instantiateStreaming(
|
||||
fetch(WASM_CONFIG.WASM_PATH),
|
||||
go.importObject,
|
||||
);
|
||||
go.run(wasmModule.instance);
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < WASM_CONFIG.INIT_TIMEOUT) {
|
||||
if ((window as any).NetBirdClient) return;
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, WASM_CONFIG.RETRY_DELAY),
|
||||
);
|
||||
}
|
||||
throw new Error("NetBird WASM failed to initialize in time");
|
||||
}, []);
|
||||
|
||||
const initIronRDP = useCallback(() => {
|
||||
if (rdpComponents.current.bridge) return;
|
||||
|
||||
installWebSocketProxy();
|
||||
rdpComponents.current = {
|
||||
bridge: new IronRDPWASMBridge(),
|
||||
inputHandler: IronRDPInputHandler,
|
||||
certificateHandler: RDPCertificateHandler,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initialize = useCallback(async (): Promise<WASMStatus> => {
|
||||
const currentStatus = netBirdStore.getState().wasmStatus;
|
||||
if (currentStatus === WASMStatus.INITIALIZED) return currentStatus;
|
||||
|
||||
if (currentStatus === WASMStatus.INITIALIZING) {
|
||||
while (netBirdStore.getState().wasmStatus === WASMStatus.INITIALIZING) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
const finalStatus = netBirdStore.getState().wasmStatus;
|
||||
if (finalStatus === WASMStatus.INITIALIZED) return finalStatus;
|
||||
throw new Error("WASM initialization failed");
|
||||
}
|
||||
|
||||
netBirdStore.setState({ wasmStatus: WASMStatus.INITIALIZING });
|
||||
try {
|
||||
await loadWASMRuntime();
|
||||
await loadGoClient();
|
||||
initIronRDP();
|
||||
netBirdStore.setState({ wasmStatus: WASMStatus.INITIALIZED });
|
||||
return WASMStatus.INITIALIZED;
|
||||
} catch (error) {
|
||||
netBirdStore.setState({
|
||||
wasmStatus: WASMStatus.UNINITIALIZED,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to initialize WASM",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}, [loadWASMRuntime, loadGoClient, initIronRDP]);
|
||||
|
||||
const initializeIronRDP = useCallback(async (): Promise<boolean> => {
|
||||
if (!rdpComponents.current.bridge) return false;
|
||||
try {
|
||||
await rdpComponents.current.bridge.initialize();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(
|
||||
async (privateKey: string): Promise<boolean> => {
|
||||
await initialize();
|
||||
|
||||
if (typeof (window as any).NetBirdClient !== "function") {
|
||||
netBirdStore.setState({
|
||||
status: NetBirdStatus.DISCONNECTED,
|
||||
error: "NetBirdClient is not available or not a function",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
netBirdStore.setState({ status: NetBirdStatus.CONNECTING });
|
||||
|
||||
try {
|
||||
netBirdClient.current = await (window as any).NetBirdClient({
|
||||
privateKey,
|
||||
logLevel: "warn",
|
||||
managementURL: config.apiOrigin,
|
||||
});
|
||||
|
||||
await netBirdClient.current.start();
|
||||
netBirdStore.setState({ status: NetBirdStatus.CONNECTED });
|
||||
return true;
|
||||
} catch (error) {
|
||||
netBirdStore.setState({
|
||||
status: NetBirdStatus.DISCONNECTED,
|
||||
error: error instanceof Error ? error.message : "Connection failed",
|
||||
});
|
||||
console.log(error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[initialize],
|
||||
);
|
||||
|
||||
const disconnect = useCallback(async (): Promise<void> => {
|
||||
if (!netBirdClient.current?.stop) {
|
||||
throw new Error("Go client not ready");
|
||||
}
|
||||
|
||||
netBirdStore.setState({ status: NetBirdStatus.DISCONNECTED });
|
||||
await netBirdClient.current.stop();
|
||||
netBirdClient.current = null;
|
||||
return Promise.resolve();
|
||||
}, []);
|
||||
|
||||
const createSSHConnection = useCallback(
|
||||
async (host: string, port: number, username: string): Promise<any> => {
|
||||
if (!netBirdClient.current?.createSSHConnection) {
|
||||
throw new Error("Go client not ready");
|
||||
}
|
||||
return netBirdClient.current.createSSHConnection(host, port, username);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const makeRequest = useCallback(async (url: string): Promise<any> => {
|
||||
if (!netBirdClient.current?.makeRequest) {
|
||||
throw new Error("Go client not ready");
|
||||
}
|
||||
return netBirdClient.current.makeRequest(url);
|
||||
}, []);
|
||||
|
||||
const proxyRequest = useCallback(async (request: any): Promise<any> => {
|
||||
if (!netBirdClient.current?.proxyRequest) {
|
||||
throw new Error("Go client not ready");
|
||||
}
|
||||
return netBirdClient.current.proxyRequest(request);
|
||||
}, []);
|
||||
|
||||
const setupRDPProxy = useCallback(
|
||||
async (hostname: string, port: string): Promise<string> => {
|
||||
if (!netBirdClient.current?.setupRDPProxy) {
|
||||
throw new Error("Go client not ready");
|
||||
}
|
||||
return netBirdClient.current.setupRDPProxy(hostname, port);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const connectTemporary = useCallback(
|
||||
async (peerId: string, rules?: string[]) => {
|
||||
const currentStatus = netBirdStore.getState().status;
|
||||
if (
|
||||
currentStatus === NetBirdStatus.CONNECTING ||
|
||||
currentStatus === NetBirdStatus.CONNECTED
|
||||
) {
|
||||
return currentStatus === NetBirdStatus.CONNECTED;
|
||||
}
|
||||
|
||||
netBirdStore.setState({ status: NetBirdStatus.CONNECTING });
|
||||
|
||||
try {
|
||||
const keyPairs = generateKeypair();
|
||||
const browser = getBrowserInfo();
|
||||
const name =
|
||||
browser.name === ""
|
||||
? "browser-client"
|
||||
: trim(
|
||||
`${browser.name.toLowerCase()}-${browser.version.toLowerCase()}-browser-client`,
|
||||
);
|
||||
await peerRequest.post(
|
||||
{
|
||||
name,
|
||||
wg_pub_key: keyPairs.publicKey,
|
||||
rules: rules ?? ["tcp/22", "tcp/3389", "tcp/44338"],
|
||||
},
|
||||
`/${peerId}/temporary-access`,
|
||||
);
|
||||
return await connect(keyPairs.privateKey);
|
||||
} catch (error) {
|
||||
netBirdStore.setState({ status: NetBirdStatus.DISCONNECTED });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[connect, peerRequest],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
initialize().catch(console.error);
|
||||
}, [initialize]);
|
||||
|
||||
return {
|
||||
status,
|
||||
wasmStatus,
|
||||
error,
|
||||
client: netBirdClient.current, // Expose the raw NetBird client
|
||||
ironRDPBridge: rdpComponents.current.bridge,
|
||||
ironRDPInputHandler: rdpComponents.current.inputHandler,
|
||||
rdpCertificateHandler: rdpComponents.current.certificateHandler,
|
||||
initialize,
|
||||
initializeIronRDP,
|
||||
connect,
|
||||
connectTemporary,
|
||||
disconnect,
|
||||
createSSHConnection,
|
||||
makeRequest,
|
||||
proxyRequest,
|
||||
setupRDPProxy,
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,3 +54,13 @@ export const isRoutingPeerSupported = (version: string, os: string) => {
|
||||
const versionNumber = parseVersionString(version);
|
||||
return versionNumber >= 366;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if native SSH is supported
|
||||
* @param version
|
||||
*/
|
||||
export const isNativeSSHSupported = (version: string) => {
|
||||
if (version == "development") return true;
|
||||
const versionNumber = parseVersionString(version);
|
||||
return versionNumber >= 999;
|
||||
};
|
||||
|
||||
200
src/utils/wireguard.ts
Normal file
200
src/utils/wireguard.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/*! SPDX-License-Identifier: GPL-2.0
|
||||
*
|
||||
* Copyright (C) 2015-2020 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
|
||||
*/
|
||||
|
||||
export interface WireguardKeypair {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
function gf(init?: number[]): Float64Array {
|
||||
const r = new Float64Array(16);
|
||||
if (init) {
|
||||
for (let i = 0; i < init.length; ++i) r[i] = init[i];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function pack(o: Uint8Array, n: Float64Array): void {
|
||||
let b: number;
|
||||
const m = gf();
|
||||
const t = gf();
|
||||
for (let i = 0; i < 16; ++i) t[i] = n[i];
|
||||
carry(t);
|
||||
carry(t);
|
||||
carry(t);
|
||||
for (let j = 0; j < 2; ++j) {
|
||||
m[0] = t[0] - 0xffed;
|
||||
for (let i = 1; i < 15; ++i) {
|
||||
m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1);
|
||||
m[i - 1] &= 0xffff;
|
||||
}
|
||||
m[15] = t[15] - 0x7fff - ((m[14] >> 16) & 1);
|
||||
b = (m[15] >> 16) & 1;
|
||||
m[14] &= 0xffff;
|
||||
cswap(t, m, 1 - b);
|
||||
}
|
||||
for (let i = 0; i < 16; ++i) {
|
||||
o[2 * i] = t[i] & 0xff;
|
||||
o[2 * i + 1] = t[i] >> 8;
|
||||
}
|
||||
}
|
||||
|
||||
function carry(o: Float64Array): void {
|
||||
for (let i = 0; i < 16; ++i) {
|
||||
o[(i + 1) % 16] += (i < 15 ? 1 : 38) * Math.floor(o[i] / 65536);
|
||||
o[i] &= 0xffff;
|
||||
}
|
||||
}
|
||||
|
||||
function cswap(p: Float64Array, q: Float64Array, b: number): void {
|
||||
const c = ~(b - 1);
|
||||
for (let i = 0; i < 16; ++i) {
|
||||
const t = c & (p[i] ^ q[i]);
|
||||
p[i] ^= t;
|
||||
q[i] ^= t;
|
||||
}
|
||||
}
|
||||
|
||||
function add(o: Float64Array, a: Float64Array, b: Float64Array): void {
|
||||
for (let i = 0; i < 16; ++i) o[i] = (a[i] + b[i]) | 0;
|
||||
}
|
||||
|
||||
function subtract(o: Float64Array, a: Float64Array, b: Float64Array): void {
|
||||
for (let i = 0; i < 16; ++i) o[i] = (a[i] - b[i]) | 0;
|
||||
}
|
||||
|
||||
function multmod(o: Float64Array, a: Float64Array, b: Float64Array): void {
|
||||
const t = new Float64Array(31);
|
||||
for (let i = 0; i < 16; ++i) {
|
||||
for (let j = 0; j < 16; ++j) t[i + j] += a[i] * b[j];
|
||||
}
|
||||
for (let i = 0; i < 15; ++i) t[i] += 38 * t[i + 16];
|
||||
for (let i = 0; i < 16; ++i) o[i] = t[i];
|
||||
carry(o);
|
||||
carry(o);
|
||||
}
|
||||
|
||||
function invert(o: Float64Array, i: Float64Array): void {
|
||||
const c = gf();
|
||||
for (let a = 0; a < 16; ++a) c[a] = i[a];
|
||||
for (let a = 253; a >= 0; --a) {
|
||||
multmod(c, c, c);
|
||||
if (a !== 2 && a !== 4) multmod(c, c, i);
|
||||
}
|
||||
for (let a = 0; a < 16; ++a) o[a] = c[a];
|
||||
}
|
||||
|
||||
function clamp(z: Uint8Array): void {
|
||||
z[31] = (z[31] & 127) | 64;
|
||||
z[0] &= 248;
|
||||
}
|
||||
|
||||
function generatePresharedKey(): Uint8Array {
|
||||
const privateKey = new Uint8Array(32);
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.crypto &&
|
||||
window.crypto.getRandomValues
|
||||
) {
|
||||
window.crypto.getRandomValues(privateKey);
|
||||
} else if (typeof require !== "undefined") {
|
||||
// Node.js fallback
|
||||
const crypto = require("crypto");
|
||||
const buf = crypto.randomBytes(32);
|
||||
for (let i = 0; i < 32; ++i) privateKey[i] = buf[i];
|
||||
} else {
|
||||
throw new Error("No secure random number generator available");
|
||||
}
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
function generatePrivateKey(): Uint8Array {
|
||||
const privateKey = generatePresharedKey();
|
||||
clamp(privateKey);
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
function generatePublicKey(privateKey: Uint8Array): Uint8Array {
|
||||
let r: number;
|
||||
const z = new Uint8Array(32);
|
||||
const a = gf([1]),
|
||||
b = gf([9]),
|
||||
c = gf(),
|
||||
d = gf([1]),
|
||||
e = gf(),
|
||||
f = gf(),
|
||||
_121665 = gf([0xdb41, 1]),
|
||||
_9 = gf([9]);
|
||||
for (let i = 0; i < 32; ++i) z[i] = privateKey[i];
|
||||
clamp(z);
|
||||
for (let i = 254; i >= 0; --i) {
|
||||
r = (z[i >>> 3] >>> (i & 7)) & 1;
|
||||
cswap(a, b, r);
|
||||
cswap(c, d, r);
|
||||
add(e, a, c);
|
||||
subtract(a, a, c);
|
||||
add(c, b, d);
|
||||
subtract(b, b, d);
|
||||
multmod(d, e, e);
|
||||
multmod(f, a, a);
|
||||
multmod(a, c, a);
|
||||
multmod(c, b, e);
|
||||
add(e, a, c);
|
||||
subtract(a, a, c);
|
||||
multmod(b, a, a);
|
||||
subtract(c, d, f);
|
||||
multmod(a, c, _121665);
|
||||
add(a, a, d);
|
||||
multmod(c, c, a);
|
||||
multmod(a, d, f);
|
||||
multmod(d, b, _9);
|
||||
multmod(b, e, e);
|
||||
cswap(a, b, r);
|
||||
cswap(c, d, r);
|
||||
}
|
||||
invert(c, c);
|
||||
multmod(a, a, c);
|
||||
pack(z, a);
|
||||
return z;
|
||||
}
|
||||
|
||||
function encodeBase64(dest: Uint8Array, src: Uint8Array): void {
|
||||
const input = Uint8Array.from([
|
||||
(src[0] >> 2) & 63,
|
||||
((src[0] << 4) | (src[1] >> 4)) & 63,
|
||||
((src[1] << 2) | (src[2] >> 6)) & 63,
|
||||
src[2] & 63,
|
||||
]);
|
||||
for (let i = 0; i < 4; ++i)
|
||||
dest[i] =
|
||||
input[i] +
|
||||
65 +
|
||||
(((25 - input[i]) >> 8) & 6) -
|
||||
(((51 - input[i]) >> 8) & 75) -
|
||||
(((61 - input[i]) >> 8) & 15) +
|
||||
(((62 - input[i]) >> 8) & 3);
|
||||
}
|
||||
|
||||
function keyToBase64(key: Uint8Array): string {
|
||||
let i: number;
|
||||
const base64 = new Uint8Array(44);
|
||||
for (i = 0; i < 32 / 3; ++i)
|
||||
encodeBase64(base64.subarray(i * 4), key.subarray(i * 3));
|
||||
encodeBase64(
|
||||
base64.subarray(i * 4),
|
||||
Uint8Array.from([key[i * 3], key[i * 3 + 1], 0]),
|
||||
);
|
||||
base64[43] = 61;
|
||||
return String.fromCharCode(...base64);
|
||||
}
|
||||
|
||||
export function generateKeypair(): WireguardKeypair {
|
||||
const privateKey = generatePrivateKey();
|
||||
const publicKey = generatePublicKey(privateKey);
|
||||
return {
|
||||
publicKey: keyToBase64(publicKey),
|
||||
privateKey: keyToBase64(privateKey),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user