diff --git a/README.md b/README.md
index 192ded26..dbba0f52 100644
--- a/README.md
+++ b/README.md
@@ -46,12 +46,13 @@ If you would like, you can support the project here!\
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
-access, SSH tunneling capabilities, remote file management, and many other tools. Termix is the perfect
+access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote SSH file management, and many other tools. Termix is the perfect
free and self-hosted alternative to Termius available for all platforms.
# Features
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components.
+- **Remote Desktop Access** - RDP, VNC, and Telnet support over the browser with complete customization and split screening
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring and support for -l or -r connections
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support.
- **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
@@ -105,7 +106,7 @@ Supported Devices:
- APK
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix on all platforms. Otherwise, view
-a sample Docker Compose file here:
+a sample Docker Compose file here (you can omit guacd and the network if you don't plan on using remote desktop features):
```yaml
services:
@@ -119,10 +120,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# Sponsors
@@ -153,7 +171,7 @@ channel, however, response times may be longer.
# Screenshots
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 00000000..cba466c6
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,32 @@
+services:
+ termix:
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - termix-data:/app/data
+ environment:
+ PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
+
+volumes:
+ termix-data:
+ driver: local
+
+networks:
+ termix-net:
+ driver: bridge
diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf
index 22f167f3..74dbae3e 100644
--- a/docker/nginx-https.conf
+++ b/docker/nginx-https.conf
@@ -218,7 +218,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/quick-connect {
+ location /host/quick-connect {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -230,8 +230,8 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location ~ ^/ssh/opkssh-chooser(/.*)?$ {
- proxy_pass http://127.0.0.1:30001/ssh/opkssh-chooser$1$is_args$args;
+ location ~ ^/host/opkssh-chooser(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
@@ -245,8 +245,8 @@ http {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
- location ~ ^/ssh/opkssh-callback(/.*)?$ {
- proxy_pass http://127.0.0.1:30001/ssh/opkssh-callback$1$is_args$args;
+ location ~ ^/host/opkssh-callback(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
@@ -260,7 +260,7 @@ http {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
- location /ssh/ {
+ location /host/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -294,7 +294,41 @@ http {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
- location /ssh/tunnel/ {
+ location ^~ /guacamole/websocket/ {
+ proxy_pass http://127.0.0.1:30008/;
+ proxy_http_version 1.1;
+
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_cache_bypass $http_upgrade;
+
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Port $server_port;
+ proxy_set_header X-Forwarded-Host $http_host;
+
+ proxy_read_timeout 86400s;
+ proxy_send_timeout 86400s;
+ proxy_connect_timeout 10s;
+
+ proxy_buffering off;
+ proxy_request_buffering off;
+
+ proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
+ }
+
+ location ~ ^/guacamole(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location /host/tunnel/ {
proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -303,7 +337,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/recent {
+ location /host/file_manager/recent {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -312,7 +346,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/pinned {
+ location /host/file_manager/pinned {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -321,7 +355,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/shortcuts {
+ location /host/file_manager/shortcuts {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -330,7 +364,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/sudo-password {
+ location /host/file_manager/sudo-password {
proxy_pass http://127.0.0.1:30004;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -339,7 +373,28 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/ssh/ {
+ location /ssh/file_manager/ {
+ client_max_body_size 5G;
+ client_body_timeout 300s;
+
+ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
+
+ proxy_pass http://127.0.0.1:30004;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s;
+ proxy_read_timeout 300s;
+
+ proxy_request_buffering off;
+ proxy_buffering off;
+ }
+
+ location /host/file_manager/ssh/ {
client_max_body_size 5G;
client_body_timeout 300s;
@@ -400,6 +455,15 @@ http {
proxy_read_timeout 60s;
}
+ location ~ ^/global-settings(/.*)?$ {
+ proxy_pass http://127.0.0.1:30005;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location ~ ^/uptime(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
@@ -428,7 +492,7 @@ http {
}
location ^~ /docker/console/ {
- proxy_pass http://127.0.0.1:30008/;
+ proxy_pass http://127.0.0.1:30009/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -439,6 +503,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Port $server_port;
+ proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index 8ac129c6..5a7eca7f 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -207,7 +207,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/quick-connect {
+ location /host/quick-connect {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -219,8 +219,8 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location ~ ^/ssh/opkssh-chooser(/.*)?$ {
- proxy_pass http://127.0.0.1:30001/ssh/opkssh-chooser$1$is_args$args;
+ location ~ ^/host/opkssh-chooser(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
@@ -234,8 +234,8 @@ http {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
- location ~ ^/ssh/opkssh-callback(/.*)?$ {
- proxy_pass http://127.0.0.1:30001/ssh/opkssh-callback$1$is_args$args;
+ location ~ ^/host/opkssh-callback(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
@@ -249,7 +249,7 @@ http {
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
- location /ssh/ {
+ location /host/ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -283,7 +283,41 @@ http {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
}
- location /ssh/tunnel/ {
+ location ^~ /guacamole/websocket/ {
+ proxy_pass http://127.0.0.1:30008/;
+ proxy_http_version 1.1;
+
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $http_host;
+ proxy_cache_bypass $http_upgrade;
+
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Port $server_port;
+ proxy_set_header X-Forwarded-Host $http_host;
+
+ proxy_read_timeout 86400s;
+ proxy_send_timeout 86400s;
+ proxy_connect_timeout 10s;
+
+ proxy_buffering off;
+ proxy_request_buffering off;
+
+ proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
+ }
+
+ location ~ ^/guacamole(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
+ location /host/tunnel/ {
proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -292,7 +326,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/recent {
+ location /host/file_manager/recent {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -301,7 +335,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/pinned {
+ location /host/file_manager/pinned {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -310,7 +344,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/shortcuts {
+ location /host/file_manager/shortcuts {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -319,7 +353,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/sudo-password {
+ location /host/file_manager/sudo-password {
proxy_pass http://127.0.0.1:30004;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
@@ -328,7 +362,28 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
- location /ssh/file_manager/ssh/ {
+ location /ssh/file_manager/ {
+ client_max_body_size 5G;
+ client_body_timeout 300s;
+
+ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
+
+ proxy_pass http://127.0.0.1:30004;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s;
+ proxy_read_timeout 300s;
+
+ proxy_request_buffering off;
+ proxy_buffering off;
+ }
+
+ location /host/file_manager/ssh/ {
client_max_body_size 5G;
client_body_timeout 300s;
@@ -389,6 +444,15 @@ http {
proxy_read_timeout 60s;
}
+ location ~ ^/global-settings(/.*)?$ {
+ proxy_pass http://127.0.0.1:30005;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location ~ ^/uptime(/.*)?$ {
proxy_pass http://127.0.0.1:30006;
proxy_http_version 1.1;
@@ -417,7 +481,7 @@ http {
}
location ^~ /docker/console/ {
- proxy_pass http://127.0.0.1:30008/;
+ proxy_pass http://127.0.0.1:30009/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -428,6 +492,8 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Port $server_port;
+ proxy_set_header X-Forwarded-Host $http_host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
diff --git a/electron-builder.json b/electron-builder.json
index 03ef1880..9bbca395 100644
--- a/electron-builder.json
+++ b/electron-builder.json
@@ -33,7 +33,7 @@
},
"buildDependenciesFromSource": false,
"nodeGypRebuild": false,
- "npmRebuild": false,
+ "npmRebuild": true,
"win": {
"target": [
{
diff --git a/electron/main.cjs b/electron/main.cjs
index 0443c389..99a12f34 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -99,8 +99,6 @@ function getBackendEntryPath() {
if (isDev) {
return path.join(appRoot, "dist", "backend", "backend", "starter.js");
}
- // In production, asar is disabled (asar: false in electron-builder.json)
- // so backend is directly in appRoot/dist
return path.join(appRoot, "dist", "backend", "backend", "starter.js");
}
@@ -133,13 +131,11 @@ function startBackendServer() {
logToFile("Data directory:", dataDir);
logToFile("Backend cwd:", appRoot);
- // Verify all required paths exist
logToFile("Checking paths...");
logToFile(" entryPath exists:", fs.existsSync(entryPath));
logToFile(" dataDir exists:", fs.existsSync(dataDir));
logToFile(" appRoot exists:", fs.existsSync(appRoot));
- // List contents of dist directory
const distPath = path.join(appRoot, "dist");
if (fs.existsSync(distPath)) {
logToFile(" dist directory contents:", fs.readdirSync(distPath));
@@ -214,7 +210,6 @@ function stopBackendServer() {
console.log("Stopping embedded backend server...");
- // Use IPC for graceful shutdown (SIGTERM doesn't work on Windows)
try {
backendProcess.send({ type: "shutdown" });
} catch {
@@ -256,7 +251,6 @@ function createTray() {
let trayIcon;
if (process.platform === "darwin") {
- // macOS: use 16x16 Template image for menu bar
const iconPath = path.join(appRoot, "public", "icons", "16x16.png");
trayIcon = nativeImage.createFromPath(iconPath);
trayIcon.setTemplateImage(true);
@@ -304,7 +298,6 @@ function createTray() {
console.log("System tray created successfully");
} catch (err) {
console.error("Failed to create system tray:", err);
- // Tray is non-critical; app still works without it
}
}
@@ -871,7 +864,6 @@ app.whenReady().then(async () => {
);
createMenu();
- // Start embedded backend server (skip in dev mode, backend runs separately via npm run dev:backend)
if (!isDev) {
const result = await startBackendServer();
logToFile("startBackendServer result:", result);
@@ -887,7 +879,6 @@ app.whenReady().then(async () => {
});
app.on("window-all-closed", () => {
- // If tray exists, keep backend alive; otherwise quit normally
if (!tray || tray.isDestroyed()) {
app.quit();
}
diff --git a/flatpak/com.karmaa.termix.flatpakref b/flatpak/com.karmaa.termix.flatpakref
index 7d2e9892..307b6dee 100644
--- a/flatpak/com.karmaa.termix.flatpakref
+++ b/flatpak/com.karmaa.termix.flatpakref
@@ -4,7 +4,6 @@ Branch=stable
Title=Termix - SSH Server Management Platform
IsRuntime=false
Url=https://github.com/Termix-SSH/Termix/releases/download/VERSION_PLACEHOLDER/termix_linux_flatpak.flatpak
-GPGKey=
RuntimeRepo=https://flathub.org/repo/flathub.flatpakrepo
Comment=Web-based server management platform with SSH terminal, tunneling, and file editing
Description=Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides SSH terminal access, tunneling capabilities, and remote file management.
diff --git a/package-lock.json b/package-lock.json
index 75c92b65..b057c248 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "termix",
- "version": "1.11.2",
+ "version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
- "version": "1.11.2",
+ "version": "2.0.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.7",
"@codemirror/commands": "^6.3.3",
@@ -35,6 +35,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9",
"@types/cytoscape": "^3.21.9",
+ "@types/guacamole-common-js": "^1.5.5",
"@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
@@ -62,6 +63,8 @@
"dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3",
"express": "^5.1.0",
+ "guacamole-common-js": "^1.5.0",
+ "guacamole-lite": "^1.2.0",
"https-proxy-agent": "^7.0.6",
"i18n-auto-translation": "^2.2.3",
"i18next": "^25.4.2",
@@ -5417,6 +5420,12 @@
"@types/node": "*"
}
},
+ "node_modules/@types/guacamole-common-js": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@types/guacamole-common-js/-/guacamole-common-js-1.5.5.tgz",
+ "integrity": "sha512-dqDYo/PhbOXFGSph23rFDRZRzXdKPXy/nsTkovFMb6P3iGrd0qGB5r5BXHmX5Cr/LK7L1TK9nYrTMbtPkhdXyg==",
+ "license": "MIT"
+ },
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -10582,6 +10591,25 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/guacamole-common-js": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/guacamole-common-js/-/guacamole-common-js-1.5.0.tgz",
+ "integrity": "sha512-zxztif3GGhKbg1RgOqwmqot8kXgv2HmHFg1EvWwd4q7UfEKvBcYZ0f+7G8HzvU+FUxF0Psqm9Kl5vCbgfrRgJg==",
+ "license": "Apache 2.0"
+ },
+ "node_modules/guacamole-lite": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/guacamole-lite/-/guacamole-lite-1.2.0.tgz",
+ "integrity": "sha512-NeSYgbT5s5rxF0SE/kzJsV5Gg0IvnqoTOCbNIUMl23z1+SshaVfLExpxrEXSGTG0cdvY5lfZC1fOAepYriaXGg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ws": "^8.15.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
diff --git a/package.json b/package.json
index 6f960e24..29a0f2c4 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
- "version": "1.11.2",
+ "version": "2.0.0",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -58,6 +58,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9",
"@types/cytoscape": "^3.21.9",
+ "@types/guacamole-common-js": "^1.5.5",
"@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
@@ -85,6 +86,8 @@
"dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3",
"express": "^5.1.0",
+ "guacamole-common-js": "^1.5.0",
+ "guacamole-lite": "^1.2.0",
"https-proxy-agent": "^7.0.6",
"i18n-auto-translation": "^2.2.3",
"i18next": "^25.4.2",
diff --git a/readme/README-AR.md b/readme/README-AR.md
index 43a9f3f6..ce7c53c7 100644
--- a/readme/README-AR.md
+++ b/readme/README-AR.md
@@ -49,6 +49,7 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
# الميزات
- **الوصول إلى طرفية SSH** - طرفية كاملة الميزات مع دعم تقسيم الشاشة (حتى 4 لوحات) مع نظام علامات تبويب شبيه بالمتصفح. يتضمن دعم تخصيص الطرفية بما في ذلك السمات الشائعة والخطوط والمكونات الأخرى
+- **الوصول إلى سطح المكتب البعيد** - دعم RDP و VNC و Telnet عبر المتصفح مع تخصيص كامل وتقسيم الشاشة
- **إدارة أنفاق SSH** - إنشاء وإدارة أنفاق SSH مع إعادة الاتصال التلقائي ومراقبة الحالة ودعم اتصالات -l أو -r
- **مدير الملفات عن بُعد** - إدارة الملفات مباشرة على الخوادم البعيدة مع دعم عرض وتحرير الكود والصور والصوت والفيديو. رفع وتنزيل وإعادة تسمية وحذف ونقل الملفات بسلاسة مع دعم sudo
- **إدارة Docker** - تشغيل وإيقاف وتعليق وحذف الحاويات. عرض إحصائيات الحاويات. التحكم في الحاوية باستخدام طرفية docker exec. لم يُصمم ليحل محل Portainer أو Dockge بل لإدارة حاوياتك ببساطة مقارنة بإنشائها
@@ -69,7 +70,7 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
- **لوحة الأوامر** - اضغط مرتين على Shift الأيسر للوصول السريع إلى اتصالات SSH باستخدام لوحة المفاتيح
- **ميزات SSH الغنية** - دعم مضيفات القفز، Warpgate، الاتصالات المبنية على TOTP، SOCKS5، التحقق من مفتاح المضيف، الملء التلقائي لكلمة المرور، [OPKSSH](https://github.com/openpubkey/opkssh)، وغيرها
- **الرسم البياني للشبكة** - تخصيص لوحة التحكم لتصور مختبرك المنزلي بناءً على اتصالات SSH مع دعم الحالة
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **علامات التبويب الدائمة** - تبقى جلسات SSH وعلامات التبويب مفتوحة عبر الأجهزة/التحديثات إذا تم تفعيلها في ملف تعريف المستخدم
# الميزات المخططة
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# الرعاة
@@ -148,7 +166,7 @@ volumes:
# لقطات الشاشة
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-CN.md b/readme/README-CN.md
index b4f73562..3afa78a8 100644
--- a/readme/README-CN.md
+++ b/readme/README-CN.md
@@ -1,97 +1,98 @@
# 仓库统计
-
- English ·
- 中文 ·
- 日本語 ·
- 한국어 ·
- Français ·
- DeutsIPAh ·
- Español ·
- Português ·
- Русский ·
- العربية ·
- हिन्दी ·
- Türkçe ·
- Tiếng Việt ·
- Italiano
+
+ English ·
+ 中文 ·
+ 日本語 ·
+ 한국어 ·
+ Français ·
+ Deutsch ·
+ Español ·
+ Português ·
+ Русский ·
+ العربية ·
+ हिन्दी ·
+ Türkçe ·
+ Tiếng Việt ·
+ Italiano



-
+
-
-
+
+
- 2025年9月1日获得
+ 2025年9月1日获得
-
-
-
+
+
+
如果你愿意,可以在这里支持这个项目!\
-[](https://github.IPAom/sponsors/LukeGus)
+[](https://github.com/sponsors/LukeGus)
# 概览
-
-
-
+
+
+
-Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
-提供 SSH 终端访问、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
+Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个多平台解决方案,通过一个直观的界面管理你的服务器和基础设施。Termix
+提供 SSH 终端访问、远程桌面控制(RDP、VNC、Telnet)、SSH 隧道功能以及远程文件管理,还会陆续添加更多工具。Termix 是适用于所有平台的完美免费自托管 Termius 替代品。
# 功能
- **SSH 终端访问** - 功能齐全的终端,具有分屏支持(最多 4 个面板)和类似浏览器的选项卡系统。包括对自定义终端的支持,包括常见终端主题、字体和其他组件
-- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能
-- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件
-- **DoIPAker 管理** - 启动、停止、暂停、删除容器。查看容器统计信息。使用 doIPAker exeIPA 终端控制容器。它不是用来替代 Portainer 或 DoIPAkge,而是用于简单管理你的容器而不是创建它们。
+- **远程桌面访问** - 通过浏览器支持 RDP、VNC 和 Telnet,具有完整的自定义和分屏功能
+- **SSH 隧道管理** - 创建和管理 SSH 隧道,具有自动重新连接和健康监控功能,支持 -l 或 -r 连接
+- **远程文件管理器** - 直接在远程服务器上管理文件,支持查看和编辑代码、图像、音频和视频。无缝上传、下载、重命名、删除和移动文件,支持 sudo
+- **Docker 管理** - 启动、停止、暂停、删除容器。查看容器统计信息。使用 docker exec 终端控制容器。它不是用来替代 Portainer 或 Dockge,而是用于简单管理你的容器而不是创建它们
- **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥
-- **服务器统计** - 在任何 SSH 服务器上查看 IPAPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息
+- **服务器统计** - 在大多数 Linux 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间、系统信息、防火墙、端口监控
- **仪表板** - 在仪表板上一目了然地查看服务器信息
-- **RBAIPA** - 创建角色并在用户/角色之间共享主机
-- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDIPA 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDIPA/本地帐户链接在一起。
-- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://doIPAs.termix.site/seIPAurity)了解更多信息。
+- **RBAC** - 创建角色并在用户/角色之间共享主机
+- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起
+- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息
- **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据
- **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向
-- **现代用户界面** - 使用 ReaIPAt、Tailwind IPASS 和 ShadIPAn 构建的简洁的桌面/移动设备友好界面。可选择基于深色或浅色模式的用户界面。
-- **语言** - 内置支持约 30 种语言(由 [IPArowdin](https://doIPAs.termix.site/translations) 管理)
-- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 maIPAOS)、PWA 以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。
-- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。
+- **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面。可选择基于深色或浅色模式的用户界面。使用 URL 路由以全屏方式打开任何连接
+- **语言** - 内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理)
+- **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)、PWA 以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序
+- **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令
- **命令历史** - 自动完成并查看以前运行的 SSH 命令
- **快速连接** - 无需保存连接数据即可连接到服务器
- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接
-- **SSH 功能丰富** - 支持跳板机、Warpgate、基于 TOTP 的连接、SOIPAKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.IPAom/openpubkey/opkssh)等。
+- **SSH 功能丰富** - 支持跳板机、Warpgate、基于 TOTP 的连接、SOCKS5、主机密钥验证、密码自动填充、[OPKSSH](https://github.com/openpubkey/opkssh)等
- **网络图** - 自定义您的仪表板,根据您的 SSH 连接可视化您的家庭实验室,支持状态显示
- **持久标签页** - 如果在用户配置文件中启用,SSH 会话和标签页在设备/刷新后保持打开状态
# 计划功能
-查看 [项目](https://github.IPAom/orgs/Termix-SSH/projeIPAts/2) 了解所有计划功能。如果你想贡献代码,请参阅 [贡献指南](https://github.IPAom/Termix-SSH/Termix/blob/main/IPAONTRIBUTING.md)。
+查看 [项目](https://github.com/orgs/Termix-SSH/projects/2) 了解所有计划功能。如果你想贡献代码,请参阅 [贡献指南](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)。
# 安装
支持的设备:
-- 网站(任何平台上的任何现代浏览器,如 IPAhrome、Safari 和 Firefox)(包括 PWA 支持)
+- 网站(任何平台上的任何现代浏览器,如 Chrome、Safari 和 Firefox)(包括 PWA 支持)
- Windows(x64/ia32)
- 便携版
- MSI 安装程序
- - IPAhoIPAolatey 软件包管理器
+ - Chocolatey 软件包管理器
- Linux(x64/ia32)
- 便携版
- AUR
- AppImage
- Deb
- Flatpak
-- maIPAOS(x64/ia32 on v12.0+)
+- macOS(x64/ia32 on v12.0+)
- Apple App Store
- DMG
- Homebrew
@@ -102,13 +103,13 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
- Google Play 商店
- APK
-访问 Termix [文档](https://doIPAs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。或者,在此处查看示例 DoIPAker IPAompose 文件:
+访问 Termix [文档](https://docs.termix.site/install) 了解有关如何在所有平台上安装 Termix 的更多信息。或者,在此处查看示例 Docker Compose 文件(如果不打算使用远程桌面功能,可以省略 guacd 和 network):
```yaml
-serviIPAes:
+services:
termix:
- image: ghIPAr.io/lukegus/termix:latest
- IPAontainer_name: termix
+ image: ghcr.io/lukegus/termix:latest
+ container_name: termix
restart: unless-stopped
ports:
- "8080:8080"
@@ -116,74 +117,91 @@ serviIPAes:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
- driver: loIPAal
+ driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# 赞助商
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
# 支持
-如果你需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.IPAom/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。
-请尽可能详细地描述你的问题,最好使用英语。你也可以加入 [DisIPAord](https://disIPAord.gg/jVQGdvHDrf) 服务器并访问支持
+如果你需要 Termix 的帮助或想要请求功能,请访问 [Issues](https://github.com/Termix-SSH/Support/issues) 页面,登录并点击 `New Issue`。
+请尽可能详细地描述你的问题,最好使用英语。你也可以加入 [Discord](https://discord.gg/jVQGdvHDrf) 服务器并访问支持
频道,但响应时间可能较长。
# 展示
-[](https://youtu.be/sjKIqfIPAK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
某些视频和图像可能已过时或可能无法完美展示功能。
# 许可证
-根据 ApaIPAhe LiIPAense Version 2.0 发布。更多信息请参见 LIIPAENSE。
+根据 Apache License Version 2.0 发布。更多信息请参见 LICENSE。
diff --git a/readme/README-DE.md b/readme/README-DE.md
index df6b83d8..9567a164 100644
--- a/readme/README-DE.md
+++ b/readme/README-DE.md
@@ -49,6 +49,7 @@ Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-S
# Funktionen
- **SSH-Terminalzugriff** - Voll ausgestattetes Terminal mit Split-Screen-Unterstützung (bis zu 4 Panels) mit einem browserähnlichen Tab-System. Enthält Unterstützung für die Anpassung des Terminals einschließlich gängiger Terminal-Themes, Schriftarten und anderer Komponenten
+- **Remote-Desktop-Zugriff** - RDP-, VNC- und Telnet-Unterstützung über den Browser mit vollständiger Anpassung und Split-Screen
- **SSH-Tunnelverwaltung** - Erstellen und verwalten Sie SSH-Tunnel mit automatischer Wiederverbindung und Gesundheitsüberwachung sowie Unterstützung für -l oder -r Verbindungen
- **Remote-Dateimanager** - Verwalten Sie Dateien direkt auf Remote-Servern mit Unterstützung für das Anzeigen und Bearbeiten von Code, Bildern, Audio und Video. Laden Sie Dateien hoch, herunter, benennen Sie sie um, löschen oder verschieben Sie sie nahtlos mit Sudo-Unterstützung.
- **Docker-Verwaltung** - Container starten, stoppen, pausieren, entfernen. Container-Statistiken anzeigen. Container über Docker-Exec-Terminal steuern. Es wurde nicht entwickelt, um Portainer oder Dockge zu ersetzen, sondern um Ihre Container einfach zu verwalten, anstatt sie zu erstellen.
@@ -69,7 +70,7 @@ Termix ist eine quelloffene, dauerhaft kostenlose, selbst gehostete All-in-One-S
- **Befehlspalette** - Doppeltippen Sie die linke Umschalttaste, um schnell auf SSH-Verbindungen mit Ihrer Tastatur zuzugreifen
- **SSH-Funktionsreich** - Unterstützt Jump-Hosts, Warpgate, TOTP-basierte Verbindungen, SOCKS5, Host-Key-Verifizierung, automatisches Ausfüllen von Passwörtern, [OPKSSH](https://github.com/openpubkey/opkssh) usw.
- **Netzwerkgraph** - Passen Sie Ihr Dashboard an, um Ihr Homelab basierend auf Ihren SSH-Verbindungen mit Statusunterstützung zu visualisieren
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **Persistente Tabs** - SSH-Sitzungen und Tabs bleiben über Geräte/Aktualisierungen hinweg offen, wenn im Benutzerprofil aktiviert
# Geplante Funktionen
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# Sponsoren
@@ -148,7 +166,7 @@ Bitte beschreiben Sie Ihr Anliegen so detailliert wie möglich, vorzugsweise auf
# Screenshots
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-ES.md b/readme/README-ES.md
index 328c9de3..27c159cf 100644
--- a/readme/README-ES.md
+++ b/readme/README-ES.md
@@ -49,6 +49,7 @@ Termix es una plataforma de gestión de servidores todo en uno, de código abier
# Características
- **Acceso a Terminal SSH** - Terminal completo con soporte de pantalla dividida (hasta 4 paneles) con un sistema de pestañas similar al navegador. Incluye soporte para personalizar el terminal incluyendo temas comunes de terminal, fuentes y otros componentes
+- **Acceso a Escritorio Remoto** - Soporte RDP, VNC y Telnet a través del navegador con personalización completa y pantalla dividida
- **Gestión de Túneles SSH** - Cree y gestione túneles SSH con reconexión automática y monitoreo de estado, con soporte para conexiones -l o -r
- **Gestor Remoto de Archivos** - Gestione archivos directamente en servidores remotos con soporte para visualizar y editar código, imágenes, audio y video. Suba, descargue, renombre, elimine y mueva archivos sin problemas con soporte sudo.
- **Gestión de Docker** - Inicie, detenga, pause, elimine contenedores. Vea estadísticas de contenedores. Controle contenedores usando el terminal Docker Exec. No fue creado para reemplazar Portainer o Dockge, sino para simplemente gestionar sus contenedores en lugar de crearlos.
@@ -69,7 +70,7 @@ Termix es una plataforma de gestión de servidores todo en uno, de código abier
- **Paleta de Comandos** - Pulse dos veces la tecla Shift izquierda para acceder rápidamente a las conexiones SSH con su teclado
- **SSH Rico en Funciones** - Soporta jump hosts, Warpgate, conexiones basadas en TOTP, SOCKS5, verificación de clave de host, autocompletado de contraseñas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
- **Gráfico de Red** - Personalice su Dashboard para visualizar su homelab basado en sus conexiones SSH con soporte de estado
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **Pestañas Persistentes** - Las sesiones SSH y pestañas permanecen abiertas entre dispositivos/actualizaciones si está habilitado en el perfil de usuario
# Características Planeadas
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# Patrocinadores
@@ -148,7 +166,7 @@ Por favor, sea lo más detallado posible en su reporte, preferiblemente escrito
# Capturas de Pantalla
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-FR.md b/readme/README-FR.md
index afe7791f..9f844a0f 100644
--- a/readme/README-FR.md
+++ b/readme/README-FR.md
@@ -49,6 +49,7 @@ Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jam
# Fonctionnalités
- **Accès terminal SSH** - Terminal complet avec support d'écran partagé (jusqu'à 4 panneaux) et un système d'onglets inspiré des navigateurs. Inclut la personnalisation du terminal avec des thèmes courants, des polices et d'autres composants
+- **Accès Bureau à Distance** - Support RDP, VNC et Telnet via navigateur avec personnalisation complète et écran partagé
- **Gestion des tunnels SSH** - Créez et gérez des tunnels SSH avec reconnexion automatique et surveillance de l'état, avec support des connexions -l ou -r
- **Gestionnaire de fichiers distant** - Gérez les fichiers directement sur les serveurs distants avec support de la visualisation et de l'édition de code, images, audio et vidéo. Téléversez, téléchargez, renommez, supprimez et déplacez des fichiers de manière fluide avec support sudo
- **Gestion Docker** - Démarrez, arrêtez, mettez en pause, supprimez des conteneurs. Consultez les statistiques des conteneurs. Contrôlez les conteneurs via le terminal docker exec. Non conçu pour remplacer Portainer ou Dockge, mais plutôt pour gérer simplement vos conteneurs plutôt que de les créer
@@ -69,7 +70,7 @@ Termix est une plateforme de gestion de serveurs tout-en-un, open source, à jam
- **Palette de commandes** - Appuyez deux fois sur Shift gauche pour accéder rapidement aux connexions SSH avec votre clavier
- **SSH riche en fonctionnalités** - Support des hôtes de rebond, Warpgate, connexions basées sur TOTP, SOCKS5, vérification des clés d'hôte, remplissage automatique des mots de passe, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
- **Graphe réseau** - Personnalisez votre tableau de bord pour visualiser votre homelab basé sur vos connexions SSH avec support des statuts
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **Onglets Persistants** - Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisations si activé dans le profil utilisateur
# Fonctionnalités prévues
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# Sponsors
@@ -147,7 +165,7 @@ Si vous avez besoin d'aide ou souhaitez demander une fonctionnalité pour Termix
# Captures d'écran
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-HI.md b/readme/README-HI.md
index 140d0c6e..543a7475 100644
--- a/readme/README-HI.md
+++ b/readme/README-HI.md
@@ -49,6 +49,7 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
# विशेषताएँ
- **SSH टर्मिनल एक्सेस** - ब्राउज़र जैसी टैब प्रणाली के साथ स्प्लिट-स्क्रीन सपोर्ट (4 पैनल तक) वाला पूर्ण-विशेषता वाला टर्मिनल। इसमें लोकप्रिय टर्मिनल थीम, फ़ॉन्ट और अन्य कंपोनेंट सहित टर्मिनल को कस्टमाइज़ करने का सपोर्ट शामिल है
+- **रिमोट डेस्कटॉप एक्सेस** - ब्राउज़र पर RDP, VNC और Telnet सपोर्ट, पूर्ण कस्टमाइज़ेशन और स्प्लिट स्क्रीन के साथ
- **SSH टनल प्रबंधन** - ऑटोमैटिक रीकनेक्शन और हेल्थ मॉनिटरिंग के साथ SSH टनल बनाएँ और प्रबंधित करें, -l या -r कनेक्शन के सपोर्ट के साथ
- **रिमोट फ़ाइल मैनेजर** - कोड, इमेज, ऑडियो और वीडियो देखने और संपादित करने के सपोर्ट के साथ रिमोट सर्वर पर सीधे फ़ाइलें प्रबंधित करें। sudo सपोर्ट के साथ फ़ाइलें अपलोड, डाउनलोड, रीनेम, डिलीट और मूव करें
- **Docker प्रबंधन** - कंटेनर शुरू, बंद, पॉज़, हटाएँ। कंटेनर स्टैट्स देखें। docker exec टर्मिनल का उपयोग करके कंटेनर को नियंत्रित करें। इसे Portainer या Dockge की जगह लेने के लिए नहीं बनाया गया बल्कि कंटेनर बनाने की तुलना में उन्हें सरलता से प्रबंधित करने के लिए बनाया गया है
@@ -69,7 +70,7 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
- **कमांड पैलेट** - अपने कीबोर्ड से SSH कनेक्शन तक त्वरित पहुँच के लिए बाएँ Shift को दो बार टैप करें
- **SSH सुविधाओं से भरपूर** - जम्प होस्ट, Warpgate, TOTP आधारित कनेक्शन, SOCKS5, होस्ट की वेरिफ़िकेशन, पासवर्ड ऑटोफ़िल, [OPKSSH](https://github.com/openpubkey/opkssh) आदि का सपोर्ट
- **नेटवर्क ग्राफ़** - स्थिति सपोर्ट के साथ अपने SSH कनेक्शन के आधार पर अपने होमलैब को विज़ुअलाइज़ करने के लिए अपना डैशबोर्ड कस्टमाइज़ करें
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **परसिस्टेंट टैब** - उपयोगकर्ता प्रोफ़ाइल में सक्षम होने पर SSH सेशन और टैब डिवाइस/रीफ्रेश के पार खुले रहते हैं
# नियोजित विशेषताएँ
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# प्रायोजक
@@ -148,7 +166,7 @@ volumes:
# स्क्रीनशॉट
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-IT.md b/readme/README-IT.md
index ed950774..2b6e2d97 100644
--- a/readme/README-IT.md
+++ b/readme/README-IT.md
@@ -48,7 +48,8 @@ Termix è una piattaforma di gestione server tutto-in-uno, open-source, per semp
# Funzionalità
-- **Accesso Terminale SSH** - Terminale completo con supporto schermo divIPA (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni
+- **Accesso Terminale SSH** - Terminale completo con supporto schermo diviso (fino a 4 pannelli) con un sistema di schede in stile browser. Include il supporto per la personalizzazione del terminale, inclusi temi, font e altri componenti comuni
+- **Accesso Desktop Remoto** - Supporto RDP, VNC e Telnet tramite browser con personalizzazione completa e schermo diviso
- **Gestione Tunnel SSH** - Crea e gestisci tunnel SSH con riconnessione automatica e monitoraggio dello stato, con supporto per connessioni -l o -r
- **Gestore File Remoto** - Gestisci i file direttamente sui server remoti con supporto per la visualizzazione e la modifica di codice, immagini, audio e video. Carica, scarica, rinomina, elimina e sposta file senza problemi con supporto sudo.
- **Gestione Docker** - Avvia, ferma, metti in pausa, rimuovi container. Visualizza le statistiche dei container. Controlla i container tramite terminale docker exec. Non è stato creato per sostituire Portainer o Dockge, ma piuttosto per gestire semplicemente i tuoi container rispetto alla loro creazione.
@@ -69,7 +70,7 @@ Termix è una piattaforma di gestione server tutto-in-uno, open-source, per semp
- **Palette Comandi** - Premi due volte shift sinistro per accedere rapidamente alle connessioni SSH con la tastiera
- **SSH Ricco di Funzionalità** - Supporta jump host, Warpgate, connessioni basate su TOTP, SOCKS5, verifica chiave host, compilazione automatica password, [OPKSSH](https://github.com/openpubkey/opkssh), ecc.
- **Grafico di Rete** - Personalizza la tua Dashboard per visualizzare il tuo homelab basato sulle connessioni SSH con supporto dello stato
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **Schede Persistenti** - Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se abilitato nel profilo utente
# Funzionalità Pianificate
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# Sponsor
@@ -148,7 +166,7 @@ Per favore, sii il più dettagliato possibile nella tua segnalazione, preferibil
# Screenshot
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-JA.md b/readme/README-JA.md
index d615845f..d0c96140 100644
--- a/readme/README-JA.md
+++ b/readme/README-JA.md
@@ -49,6 +49,7 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
# 機能
- **SSHターミナルアクセス** - ブラウザ風タブシステムによる分割画面対応(最大4パネル)のフル機能ターミナル。一般的なターミナルテーマ、フォント、その他のコンポーネントを含むターミナルカスタマイズに対応
+- **リモートデスクトップアクセス** - ブラウザ上でRDP、VNC、Telnetをサポート、完全なカスタマイズと分割画面に対応
- **SSHトンネル管理** - 自動再接続とヘルスモニタリング機能を備えたSSHトンネルの作成・管理、-l または -r 接続に対応
- **リモートファイルマネージャー** - コード、画像、音声、動画の表示・編集に対応し、リモートサーバー上のファイルを直接管理。sudo対応でファイルのアップロード、ダウンロード、名前変更、削除、移動をシームレスに実行
- **Docker管理** - コンテナの起動、停止、一時停止、削除。コンテナの統計情報を表示。docker execターミナルでコンテナを操作。PortainerやDockgeの代替ではなく、コンテナの作成よりも簡易管理を目的としています
@@ -69,7 +70,7 @@ Termixは、オープンソースで永久無料のセルフホスト型オー
- **コマンドパレット** - 左Shiftキーを2回押すことで、キーボードからSSH接続に素早くアクセス
- **SSH機能充実** - ジャンプホスト、Warpgate、TOTPベースの接続、SOCKS5、ホストキー検証、パスワード自動入力、[OPKSSH](https://github.com/openpubkey/opkssh)などに対応
- **ネットワークグラフ** - ダッシュボードをカスタマイズして、SSH接続に基づくホームラボのネットワークをステータス表示付きで可視化
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **永続タブ** - ユーザープロフィールで有効にすると、SSHセッションとタブがデバイス/更新をまたいで開いたまま保持されます
# 予定されている機能
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# スポンサー
@@ -147,7 +165,7 @@ Termixに関するヘルプや機能リクエストが必要な場合は、[Issu
# スクリーンショット
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-KO.md b/readme/README-KO.md
index 8cd37b75..7deeb859 100644
--- a/readme/README-KO.md
+++ b/readme/README-KO.md
@@ -49,6 +49,7 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
# 기능
- **SSH 터미널 접속** - 브라우저 스타일 탭 시스템과 분할 화면 지원(최대 4개 패널)을 갖춘 완전한 기능의 터미널. 일반 터미널 테마, 글꼴 및 기타 구성 요소를 포함한 터미널 사용자 정의 지원
+- **원격 데스크톱 접속** - 완전한 사용자 정의와 분할 화면을 지원하는 브라우저 기반 RDP, VNC, Telnet 지원
- **SSH 터널 관리** - 자동 재연결 및 상태 모니터링 기능을 갖춘 SSH 터널 생성 및 관리, -l 또는 -r 연결 지원
- **원격 파일 관리자** - 코드, 이미지, 오디오, 비디오의 보기 및 편집을 지원하여 원격 서버에서 파일을 직접 관리. sudo 지원으로 파일 업로드, 다운로드, 이름 변경, 삭제, 이동을 원활하게 수행
- **Docker 관리** - 컨테이너 시작, 중지, 일시 정지, 제거. 컨테이너 통계 보기. docker exec 터미널로 컨테이너 제어. Portainer나 Dockge를 대체하기 위한 것이 아니라 컨테이너 생성보다는 간편한 관리를 목적으로 합니다
@@ -69,7 +70,7 @@ Termix는 오픈 소스이며 영구 무료인 셀프 호스팅 올인원 서버
- **명령어 팔레트** - 왼쪽 Shift 키를 두 번 눌러 키보드로 SSH 연결에 빠르게 접근
- **풍부한 SSH 기능** - 점프 호스트, Warpgate, TOTP 기반 연결, SOCKS5, 호스트 키 검증, 비밀번호 자동 입력, [OPKSSH](https://github.com/openpubkey/opkssh) 등 지원
- **네트워크 그래프** - 대시보드를 사용자 정의하여 SSH 연결 기반의 홈랩 네트워크를 상태 표시와 함께 시각화
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **지속 탭** - 사용자 프로필에서 활성화된 경우 SSH 세션 및 탭이 기기/새로 고침 간에 열린 상태 유지
# 계획된 기능
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# 스폰서
@@ -147,7 +165,7 @@ Termix에 대한 도움이 필요하거나 기능을 요청하려면 [Issues](ht
# 스크린샷
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-PT.md b/readme/README-PT.md
index cf5d735d..40fedffe 100644
--- a/readme/README-PT.md
+++ b/readme/README-PT.md
@@ -49,6 +49,7 @@ Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código a
# Funcionalidades
- **Acesso ao Terminal SSH** - Terminal completo com suporte a tela dividida (até 4 painéis) com um sistema de abas similar ao navegador. Inclui suporte para personalização do terminal incluindo temas comuns de terminal, fontes e outros componentes
+- **Acesso à Área de Trabalho Remota** - Suporte a RDP, VNC e Telnet pelo navegador com personalização completa e tela dividida
- **Gerenciamento de Túneis SSH** - Crie e gerencie túneis SSH com reconexão automática e monitoramento de saúde, com suporte para conexões -l ou -r
- **Gerenciador Remoto de Arquivos** - Gerencie arquivos diretamente em servidores remotos com suporte para visualizar e editar código, imagens, áudio e vídeo. Faça upload, download, renomeie, exclua e mova arquivos facilmente com suporte sudo.
- **Gerenciamento de Docker** - Inicie, pare, pause, remova contêineres. Visualize estatísticas de contêineres. Controle contêineres usando o terminal Docker Exec. Não foi feito para substituir Portainer ou Dockge, mas sim para simplesmente gerenciar seus contêineres em vez de criá-los.
@@ -69,7 +70,7 @@ Termix é uma plataforma de gerenciamento de servidores tudo-em-um, de código a
- **Paleta de Comandos** - Pressione duas vezes a tecla Shift esquerda para acessar rapidamente as conexões SSH com seu teclado
- **SSH Rico em Funcionalidades** - Suporta jump hosts, Warpgate, conexões baseadas em TOTP, SOCKS5, verificação de chave do host, preenchimento automático de senhas, [OPKSSH](https://github.com/openpubkey/opkssh), etc.
- **Gráfico de Rede** - Personalize seu Dashboard para visualizar seu homelab baseado nas suas conexões SSH com suporte de status
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **Abas Persistentes** - Sessões SSH e abas permanecem abertas entre dispositivos/atualizações se habilitado no perfil do usuário
# Funcionalidades Planejadas
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# Patrocinadores
@@ -148,7 +166,7 @@ Por favor, seja o mais detalhado possível no seu relato, preferencialmente escr
# Capturas de Tela
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-RU.md b/readme/README-RU.md
index 86af78f9..23b2a490 100644
--- a/readme/README-RU.md
+++ b/readme/README-RU.md
@@ -49,6 +49,7 @@ Termix — это платформа для управления сервера
# Возможности
- **Доступ к SSH-терминалу** — Полнофункциональный терминал с поддержкой разделения экрана (до 4 панелей) и системой вкладок, как в браузере. Включает поддержку настройки терминала, включая популярные темы, шрифты и другие компоненты
+- **Доступ к удалённому рабочему столу** — Поддержка RDP, VNC и Telnet через браузер с полной настройкой и разделением экрана
- **Управление SSH-туннелями** — Создание и управление SSH-туннелями с автоматическим переподключением и мониторингом состояния, с поддержкой соединений -l и -r
- **Удалённый файловый менеджер** — Управление файлами непосредственно на удалённых серверах с поддержкой просмотра и редактирования кода, изображений, аудио и видео. Загрузка, скачивание, переименование, удаление и перемещение файлов с поддержкой sudo
- **Управление Docker** — Запуск, остановка, приостановка, удаление контейнеров. Просмотр статистики контейнеров. Управление контейнером через терминал docker exec. Не предназначен для замены Portainer или Dockge, а скорее для простого управления контейнерами по сравнению с их созданием
@@ -69,7 +70,7 @@ Termix — это платформа для управления сервера
- **Командная палитра** — Двойное нажатие левого Shift для быстрого доступа к SSH-подключениям с клавиатуры
- **Богатый функционал SSH** — Поддержка jump-хостов, Warpgate, подключений на основе TOTP, SOCKS5, верификации ключей хоста, автозаполнения паролей, [OPKSSH](https://github.com/openpubkey/opkssh) и др.
- **Сетевой граф** — Настройте панель управления для визуализации вашей домашней лаборатории на основе SSH-подключений с поддержкой статусов
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **Постоянные вкладки** — SSH-сессии и вкладки остаются открытыми на всех устройствах/при обновлении страницы, если включено в профиле пользователя
# Запланированные функции
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# Спонсоры
@@ -148,7 +166,7 @@ volumes:
# Скриншоты
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-TR.md b/readme/README-TR.md
index d52345e5..855058c4 100644
--- a/readme/README-TR.md
+++ b/readme/README-TR.md
@@ -49,6 +49,7 @@ Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındıra
# Özellikler
- **SSH Terminal Erişimi** - Tarayıcı benzeri sekme sistemiyle bölünmüş ekran desteğine sahip (4 panele kadar) tam özellikli terminal. Yaygın terminal temaları, yazı tipleri ve diğer bileşenler dahil olmak üzere terminal özelleştirme desteği içerir
+- **Uzak Masaüstü Erişimi** - Tam özelleştirme ve bölünmüş ekran ile tarayıcı üzerinden RDP, VNC ve Telnet desteği
- **SSH Tünel Yönetimi** - Otomatik yeniden bağlanma ve sağlık izleme ile SSH tünelleri oluşturun ve yönetin, -l veya -r bağlantıları desteğiyle
- **Uzak Dosya Yöneticisi** - Uzak sunuculardaki dosyaları doğrudan yönetin; kod, görüntü, ses ve video görüntüleme ve düzenleme desteğiyle. Sudo desteğiyle dosyaları sorunsuzca yükleyin, indirin, yeniden adlandırın, silin ve taşıyın.
- **Docker Yönetimi** - Konteynerleri başlatın, durdurun, duraklatın, kaldırın. Konteyner istatistiklerini görüntüleyin. Docker exec terminali kullanarak konteyneri kontrol edin. Portainer veya Dockge'nin yerini almak için değil, konteynerlerinizi oluşturmak yerine basitçe yönetmek için tasarlanmıştır.
@@ -69,7 +70,7 @@ Termix, açık kaynaklı, sonsuza kadar ücretsiz, kendi sunucunuzda barındıra
- **Komut Paleti** - Sol shift tuşuna iki kez basarak SSH bağlantılarına klavyenizle hızlıca erişin
- **SSH Zengin Özellikler** - Atlama ana bilgisayarları, Warpgate, TOTP tabanlı bağlantılar, SOCKS5, ana bilgisayar anahtar doğrulama, otomatik şifre doldurma, [OPKSSH](https://github.com/openpubkey/opkssh) vb. destekler.
- **Ağ Grafiği** - Kontrol panelinizi, SSH bağlantılarınıza dayalı olarak ev laboratuvarınızı durum desteğiyle görselleştirmek için özelleştirin
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **Kalıcı Sekmeler** - Kullanıcı profilinde etkinleştirilmişse SSH oturumları ve sekmeler cihazlar/yenilemeler arasında açık kalır
# Planlanan Özellikler
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# Sponsorlar
@@ -148,7 +166,7 @@ Lütfen sorununuzu mümkün olduğunca ayrıntılı yazın, tercihen İngilizce
# Ekran Görüntüleri
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/readme/README-VI.md b/readme/README-VI.md
index 634e3b36..1f084725 100644
--- a/readme/README-VI.md
+++ b/readme/README-VI.md
@@ -49,6 +49,7 @@ Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồ
# Tính Năng
- **Truy Cập Terminal SSH** - Terminal đầy đủ tính năng với hỗ trợ chia màn hình (lên đến 4 bảng) với hệ thống tab kiểu trình duyệt. Bao gồm hỗ trợ tùy chỉnh terminal bao gồm các chủ đề terminal phổ biến, phông chữ và các thành phần khác
+- **Truy Cập Màn Hình Từ Xa** - Hỗ trợ RDP, VNC và Telnet qua trình duyệt với đầy đủ tùy chỉnh và chia màn hình
- **Quản Lý Đường Hầm SSH** - Tạo và quản lý đường hầm SSH với tự động kết nối lại và giám sát sức khỏe, hỗ trợ kết nối -l hoặc -r
- **Trình Quản Lý Tệp Từ Xa** - Quản lý tệp trực tiếp trên máy chủ từ xa với hỗ trợ xem và chỉnh sửa mã, hình ảnh, âm thanh và video. Tải lên, tải xuống, đổi tên, xóa và di chuyển tệp liền mạch với hỗ trợ sudo.
- **Quản Lý Docker** - Khởi động, dừng, tạm dừng, xóa container. Xem thống kê container. Điều khiển container bằng terminal docker exec. Không được tạo ra để thay thế Portainer hay Dockge mà đơn giản là để quản lý container của bạn thay vì tạo mới chúng.
@@ -69,7 +70,7 @@ Termix là nền tảng quản lý máy chủ tất cả trong một, mã nguồ
- **Bảng Lệnh** - Nhấn đúp phím shift trái để truy cập nhanh các kết nối SSH bằng bàn phím
- **SSH Giàu Tính Năng** - Hỗ trợ jump host, Warpgate, kết nối dựa trên TOTP, SOCKS5, xác minh khóa máy chủ, tự động điền mật khẩu, [OPKSSH](https://github.com/openpubkey/opkssh), v.v.
- **Biểu Đồ Mạng** - Tùy chỉnh Bảng Điều Khiển để trực quan hóa homelab của bạn dựa trên các kết nối SSH với hỗ trợ trạng thái
-- **Persistent Tabs** - SSH sessions and tabs stay open across devices/refreshes if enabled in user profile
+- **Tab Liên Tục** - Các phiên SSH và tab vẫn mở trên các thiết bị/lần làm mới nếu được bật trong hồ sơ người dùng
# Tính Năng Dự Kiến
@@ -115,10 +116,27 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
+ depends_on:
+ - guacd
+ networks:
+ - termix-net
+
+ guacd:
+ image: guacamole/guacd:latest
+ container_name: guacd
+ restart: unless-stopped
+ ports:
+ - "4822:4822"
+ networks:
+ - termix-net
volumes:
termix-data:
driver: local
+
+networks:
+ termix-net:
+ driver: bridge
```
# Nhà Tài Trợ
@@ -148,7 +166,7 @@ Vui lòng mô tả vấn đề càng chi tiết càng tốt, ưu tiên viết b
# Ảnh Chụp Màn Hình
-[](https://youtu.be/sjKIqfCK0NY)
+[](https://www.youtube.com/@TermixSSH/videos)
diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts
index ac02bce0..dd6adb91 100644
--- a/src/backend/dashboard.ts
+++ b/src/backend/dashboard.ts
@@ -4,7 +4,7 @@ import cookieParser from "cookie-parser";
import { getDb, DatabaseSaveTrigger } from "./database/db/index.js";
import {
recentActivity,
- sshData,
+ hosts,
hostAccess,
dashboardPreferences,
} from "./database/db/schema.js";
@@ -176,7 +176,7 @@ app.get("/activity/recent", async (req, res) => {
* properties:
* type:
* type: string
- * enum: [terminal, file_manager, server_stats, tunnel, docker]
+ * enum: [terminal, file_manager, server_stats, tunnel, docker, telnet, vnc, rdp]
* hostId:
* type: integer
* hostName:
@@ -219,11 +219,14 @@ app.post("/activity/log", async (req, res) => {
"server_stats",
"tunnel",
"docker",
+ "telnet",
+ "vnc",
+ "rdp",
].includes(type)
) {
return res.status(400).json({
error:
- "Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', or 'docker'",
+ "Invalid activity type. Must be 'terminal', 'file_manager', 'server_stats', 'tunnel', 'docker', 'telnet', 'vnc', or 'rdp'",
});
}
@@ -252,8 +255,8 @@ app.post("/activity/log", async (req, res) => {
const ownedHosts = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts
index 322426b4..2ea0690d 100644
--- a/src/backend/database/database.ts
+++ b/src/backend/database/database.ts
@@ -3,11 +3,12 @@ import bodyParser from "body-parser";
import multer from "multer";
import cookieParser from "cookie-parser";
import userRoutes from "./routes/users.js";
-import sshRoutes from "./routes/ssh.js";
+import hostRoutes from "./routes/host.js";
import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js";
import terminalRoutes from "./routes/terminal.js";
+import guacamoleRoutes from "../guacamole/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import cors from "cors";
@@ -28,7 +29,7 @@ import { parseUserAgent } from "../utils/user-agent-parser.js";
import { getProxyAgent } from "../utils/proxy-agent.js";
import {
users,
- sshData,
+ hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
@@ -846,8 +847,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const sshHosts = await getDb()
.select()
- .from(sshData)
- .where(eq(sshData.userId, userId));
+ .from(hosts)
+ .where(eq(hosts.userId, userId));
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -1267,13 +1268,13 @@ app.post(
try {
const existing = await mainDb
.select()
- .from(sshData)
+ .from(hosts)
.where(
and(
- eq(sshData.userId, userId),
- eq(sshData.ip, host.ip),
- eq(sshData.port, host.port),
- eq(sshData.username, host.username),
+ eq(hosts.userId, userId),
+ eq(hosts.ip, host.ip),
+ eq(hosts.port, host.port),
+ eq(hosts.username, host.username),
),
);
@@ -1341,7 +1342,7 @@ app.post(
userId,
userDataKey,
);
- await mainDb.insert(sshData).values(encrypted);
+ await mainDb.insert(hosts).values(encrypted);
result.summary.sshHostsImported++;
} catch (hostError) {
result.summary.errors.push(
@@ -1759,11 +1760,12 @@ app.post("/database/restore", requireAdmin, async (req, res) => {
});
app.use("/users", userRoutes);
-app.use("/ssh", sshRoutes);
+app.use("/host", hostRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes);
app.use("/terminal", terminalRoutes);
+app.use("/guacamole", guacamoleRoutes);
app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index b2ae22d5..513955a9 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -427,9 +427,7 @@ async function initializeCompleteDatabase(): Promise {
`);
try {
- sqlite
- .prepare("DELETE FROM sessions WHERE expires_at < datetime('now')")
- .run();
+ sqlite.prepare("DELETE FROM sessions").run();
} catch (e) {
databaseLogger.warn("Could not clear expired sessions on startup", {
operation: "db_init_session_cleanup_failed",
@@ -474,6 +472,42 @@ async function initializeCompleteDatabase(): Promise {
error: e,
});
}
+
+ try {
+ const row = sqlite
+ .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
+ .get();
+ if (!row) {
+ sqlite
+ .prepare(
+ "INSERT INTO settings (key, value) VALUES ('guac_enabled', 'true')",
+ )
+ .run();
+ }
+ } catch (e) {
+ databaseLogger.warn("Could not initialize guac_enabled setting", {
+ operation: "db_init",
+ error: e,
+ });
+ }
+
+ try {
+ const row = sqlite
+ .prepare("SELECT value FROM settings WHERE key = 'guac_url'")
+ .get();
+ if (!row) {
+ sqlite
+ .prepare(
+ "INSERT INTO settings (key, value) VALUES ('guac_url', 'guacd:4822')",
+ )
+ .run();
+ }
+ } catch (e) {
+ databaseLogger.warn("Could not initialize guac_url setting", {
+ operation: "db_init",
+ error: e,
+ });
+ }
}
const addColumnIfNotExists = (
@@ -591,6 +625,11 @@ const migrateSchema = () => {
);
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
+ addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
+ addColumnIfNotExists("ssh_data", "domain", "TEXT");
+ addColumnIfNotExists("ssh_data", "security", "TEXT");
+ addColumnIfNotExists("ssh_data", "ignore_cert", "INTEGER NOT NULL DEFAULT 0");
+ addColumnIfNotExists("ssh_data", "guacamole_config", "TEXT");
addColumnIfNotExists("ssh_data", "notes", "TEXT");
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index 3a17c077..d77200ce 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -64,11 +64,12 @@ export const trustedDevices = sqliteTable("trusted_devices", {
.default(sql`CURRENT_TIMESTAMP`),
});
-export const sshData = sqliteTable("ssh_data", {
+export const hosts = sqliteTable("ssh_data", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
+ connectionType: text("connection_type").notNull().default("ssh"),
name: text("name"),
ip: text("ip").notNull(),
port: integer("port").notNull(),
@@ -124,9 +125,14 @@ export const sshData = sqliteTable("ssh_data", {
.default(false),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
+ dockerConfig: text("docker_config"),
terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"),
notes: text("notes"),
+ domain: text("domain"),
+ security: text("security"),
+ ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false),
+ guacamoleConfig: text("guacamole_config"),
useSocks5: integer("use_socks5", { mode: "boolean" }),
socks5Host: text("socks5_host"),
@@ -157,7 +163,7 @@ export const fileManagerRecent = sqliteTable("file_manager_recent", {
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
- .references(() => sshData.id, { onDelete: "cascade" }),
+ .references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
@@ -172,7 +178,7 @@ export const fileManagerPinned = sqliteTable("file_manager_pinned", {
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
- .references(() => sshData.id, { onDelete: "cascade" }),
+ .references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
@@ -187,7 +193,7 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
- .references(() => sshData.id, { onDelete: "cascade" }),
+ .references(() => hosts.id, { onDelete: "cascade" }),
name: text("name").notNull(),
path: text("path").notNull(),
createdAt: text("created_at")
@@ -246,7 +252,7 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
.references(() => sshCredentials.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
- .references(() => sshData.id, { onDelete: "cascade" }),
+ .references(() => hosts.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -313,7 +319,7 @@ export const recentActivity = sqliteTable("recent_activity", {
type: text("type").notNull(),
hostId: integer("host_id")
.notNull()
- .references(() => sshData.id, { onDelete: "cascade" }),
+ .references(() => hosts.id, { onDelete: "cascade" }),
hostName: text("host_name"),
timestamp: text("timestamp")
.notNull()
@@ -327,7 +333,7 @@ export const commandHistory = sqliteTable("command_history", {
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
- .references(() => sshData.id, { onDelete: "cascade" }),
+ .references(() => hosts.id, { onDelete: "cascade" }),
command: text("command").notNull(),
executedAt: text("executed_at")
.notNull()
@@ -367,7 +373,7 @@ export const hostAccess = sqliteTable("host_access", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
- .references(() => sshData.id, { onDelete: "cascade" }),
+ .references(() => hosts.id, { onDelete: "cascade" }),
userId: text("user_id")
.references(() => users.id, { onDelete: "cascade" }),
@@ -492,7 +498,7 @@ export const sessionRecordings = sqliteTable("session_recordings", {
hostId: integer("host_id")
.notNull()
- .references(() => sshData.id, { onDelete: "cascade" }),
+ .references(() => hosts.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
@@ -523,7 +529,7 @@ export const opksshTokens = sqliteTable("opkssh_tokens", {
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
- .references(() => sshData.id, { onDelete: "cascade" }),
+ .references(() => hosts.id, { onDelete: "cascade" }),
sshCert: text("ssh_cert", { length: 8192 }).notNull(),
privateKey: text("private_key", { length: 8192 }).notNull(),
diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts
index 86a3cc92..58e13c97 100644
--- a/src/backend/database/routes/credentials.ts
+++ b/src/backend/database/routes/credentials.ts
@@ -7,7 +7,7 @@ import { db } from "../db/index.js";
import {
sshCredentials,
sshCredentialUsage,
- sshData,
+ hosts,
hostAccess,
} from "../db/schema.js";
import { eq, and, desc, sql } from "drizzle-orm";
@@ -606,9 +606,8 @@ router.put(
userId,
);
- const { SharedCredentialManager } = await import(
- "../../utils/shared-credential-manager.js"
- );
+ const { SharedCredentialManager } =
+ await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.updateSharedCredentialsForOriginal(
parseInt(id),
@@ -691,17 +690,14 @@ router.delete(
const hostsUsingCredential = await db
.select()
- .from(sshData)
+ .from(hosts)
.where(
- and(
- eq(sshData.credentialId, parseInt(id)),
- eq(sshData.userId, userId),
- ),
+ and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
);
if (hostsUsingCredential.length > 0) {
await db
- .update(sshData)
+ .update(hosts)
.set({
credentialId: null,
password: null,
@@ -710,10 +706,7 @@ router.delete(
authType: "password",
})
.where(
- and(
- eq(sshData.credentialId, parseInt(id)),
- eq(sshData.userId, userId),
- ),
+ and(eq(hosts.credentialId, parseInt(id)), eq(hosts.userId, userId)),
);
for (const host of hostsUsingCredential) {
@@ -737,9 +730,8 @@ router.delete(
}
}
- const { SharedCredentialManager } = await import(
- "../../utils/shared-credential-manager.js"
- );
+ const { SharedCredentialManager } =
+ await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
await sharedCredManager.deleteSharedCredentialsForOriginal(parseInt(id));
@@ -837,7 +829,7 @@ router.post(
const credential = credentials[0];
await db
- .update(sshData)
+ .update(hosts)
.set({
credentialId: parseInt(credentialId),
username: (credential.username as string) || "",
@@ -848,9 +840,7 @@ router.post(
keyType: null,
updatedAt: new Date().toISOString(),
})
- .where(
- and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
- );
+ .where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId)));
await db.insert(sshCredentialUsage).values({
credentialId: parseInt(credentialId),
@@ -917,17 +907,17 @@ router.get(
}
try {
- const hosts = await db
+ const hostsUsingCredential = await db
.select()
- .from(sshData)
+ .from(hosts)
.where(
and(
- eq(sshData.credentialId, parseInt(credentialId)),
- eq(sshData.userId, userId),
+ eq(hosts.credentialId, parseInt(credentialId)),
+ eq(hosts.userId, userId),
),
);
- res.json(hosts.map((host) => formatSSHHostOutput(host)));
+ res.json(hostsUsingCredential.map((host) => formatSSHHostOutput(host)));
} catch (err) {
authLogger.error("Failed to fetch hosts using credential", err);
res.status(500).json({
@@ -1942,7 +1932,7 @@ router.post(
});
}
const targetHost = await SimpleDBOps.select(
- db.select().from(sshData).where(eq(sshData.id, targetHostId)).limit(1),
+ db.select().from(hosts).where(eq(hosts.id, targetHostId)).limit(1),
"ssh_data",
userId,
);
diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/host.ts
similarity index 88%
rename from src/backend/database/routes/ssh.ts
rename to src/backend/database/routes/host.ts
index d164d33d..bf8931e0 100644
--- a/src/backend/database/routes/ssh.ts
+++ b/src/backend/database/routes/host.ts
@@ -2,7 +2,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
import { db } from "../db/index.js";
import {
- sshData,
+ hosts,
sshCredentials,
sshCredentialUsage,
fileManagerRecent,
@@ -90,6 +90,12 @@ function transformHostResponse(
socks5ProxyChain: host.socks5ProxyChain
? JSON.parse(host.socks5ProxyChain as string)
: [],
+ domain: host.domain || undefined,
+ security: host.security || undefined,
+ ignoreCert: !!host.ignoreCert,
+ guacamoleConfig: host.guacamoleConfig
+ ? JSON.parse(host.guacamoleConfig as string)
+ : undefined,
};
}
@@ -139,12 +145,9 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
try {
const autostartHosts = await db
.select()
- .from(sshData)
+ .from(hosts)
.where(
- and(
- eq(sshData.enableTunnel, true),
- isNotNull(sshData.tunnelConnections),
- ),
+ and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
);
const result = autostartHosts
@@ -235,7 +238,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
.json({ error: "Invalid internal authentication token" });
}
- const allHosts = await db.select().from(sshData);
+ const allHosts = await db.select().from(hosts);
const result = allHosts.map((host) => {
const tunnelConnections = host.tunnelConnections
@@ -334,6 +337,7 @@ router.post(
}
const {
+ connectionType,
name,
folder,
tags,
@@ -363,8 +367,13 @@ router.post(
jumpHosts,
quickActions,
statsConfig,
+ dockerConfig,
terminalConfig,
forceKeyboardInteractive,
+ domain,
+ security,
+ ignoreCert,
+ guacamoleConfig,
notes,
useSocks5,
socks5Host,
@@ -397,8 +406,10 @@ router.post(
}
const effectiveAuthType = authType || authMethod;
+ const effectiveConnectionType = connectionType || "ssh";
const sshDataObj: Record = {
userId: userId,
+ connectionType: effectiveConnectionType,
name,
folder: folder || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
@@ -431,12 +442,21 @@ router.post(
? statsConfig
: JSON.stringify(statsConfig)
: null,
+ dockerConfig: dockerConfig
+ ? typeof dockerConfig === "string"
+ ? dockerConfig
+ : JSON.stringify(dockerConfig)
+ : null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
+ domain: domain || null,
+ security: security || null,
+ ignoreCert: ignoreCert ? 1 : 0,
+ guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
notes: notes || null,
sudoPassword: sudoPassword || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -449,7 +469,13 @@ router.post(
: null,
};
- if (effectiveAuthType === "password") {
+ // For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
+ if (effectiveConnectionType !== "ssh") {
+ sshDataObj.password = password || null;
+ sshDataObj.key = null;
+ sshDataObj.keyPassword = null;
+ sshDataObj.keyType = null;
+ } else if (effectiveAuthType === "password") {
sshDataObj.password = password || null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
@@ -501,7 +527,7 @@ router.post(
try {
const result = await SimpleDBOps.insert(
- sshData,
+ hosts,
"ssh_data",
sshDataObj,
userId,
@@ -532,7 +558,7 @@ router.post(
try {
const axios = (await import("axios")).default;
- const statsPort = process.env.STATS_PORT || 30005;
+ const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-updated`,
{ hostId: createdHost.id },
@@ -818,6 +844,7 @@ router.put(
}
const {
+ connectionType,
name,
folder,
tags,
@@ -847,8 +874,13 @@ router.put(
jumpHosts,
quickActions,
statsConfig,
+ dockerConfig,
terminalConfig,
forceKeyboardInteractive,
+ domain,
+ security,
+ ignoreCert,
+ guacamoleConfig,
notes,
useSocks5,
socks5Host,
@@ -884,6 +916,7 @@ router.put(
const effectiveAuthType = authType || authMethod;
const sshDataObj: Record = {
+ connectionType: connectionType || "ssh",
name,
folder,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
@@ -916,12 +949,21 @@ router.put(
? statsConfig
: JSON.stringify(statsConfig)
: null,
+ dockerConfig: dockerConfig
+ ? typeof dockerConfig === "string"
+ ? dockerConfig
+ : JSON.stringify(dockerConfig)
+ : null,
terminalConfig: terminalConfig
? typeof terminalConfig === "string"
? terminalConfig
: JSON.stringify(terminalConfig)
: null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
+ domain: domain || null,
+ security: security || null,
+ ignoreCert: ignoreCert ? 1 : 0,
+ guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null,
notes: notes || null,
sudoPassword: sudoPassword || null,
useSocks5: useSocks5 ? 1 : 0,
@@ -934,7 +976,15 @@ router.put(
: null,
};
- if (effectiveAuthType === "password") {
+ // For non-SSH hosts (RDP, VNC, Telnet), always save password if provided
+ if ((connectionType || "ssh") !== "ssh") {
+ if (password) {
+ sshDataObj.password = password;
+ }
+ sshDataObj.key = null;
+ sshDataObj.keyPassword = null;
+ sshDataObj.keyType = null;
+ } else if (effectiveAuthType === "password") {
if (password) {
sshDataObj.password = password;
}
@@ -1021,12 +1071,12 @@ router.put(
const hostRecord = await db
.select({
- userId: sshData.userId,
- credentialId: sshData.credentialId,
- authType: sshData.authType,
+ userId: hosts.userId,
+ credentialId: hosts.credentialId,
+ authType: hosts.authType,
})
- .from(sshData)
- .where(eq(sshData.id, Number(hostId)))
+ .from(hosts)
+ .where(eq(hosts.id, Number(hostId)))
.limit(1);
if (hostRecord.length === 0) {
@@ -1072,9 +1122,9 @@ router.put(
}
await SimpleDBOps.update(
- sshData,
+ hosts,
"ssh_data",
- eq(sshData.id, Number(hostId)),
+ eq(hosts.id, Number(hostId)),
sshDataObj,
ownerId,
);
@@ -1082,8 +1132,8 @@ router.put(
const updatedHosts = await SimpleDBOps.select(
db
.select()
- .from(sshData)
- .where(eq(sshData.id, Number(hostId))),
+ .from(hosts)
+ .where(eq(hosts.id, Number(hostId))),
"ssh_data",
ownerId,
);
@@ -1110,7 +1160,7 @@ router.put(
try {
const axios = (await import("axios")).default;
- const statsPort = process.env.STATS_PORT || 30005;
+ const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-updated`,
{ hostId: parseInt(hostId) },
@@ -1186,62 +1236,67 @@ router.get(
const rawData = await db
.select({
- id: sshData.id,
- userId: sshData.userId,
- name: sshData.name,
- ip: sshData.ip,
- port: sshData.port,
- username: sshData.username,
- folder: sshData.folder,
- tags: sshData.tags,
- pin: sshData.pin,
- authType: sshData.authType,
- password: sshData.password,
- key: sshData.key,
- keyPassword: sshData.keyPassword,
- keyType: sshData.keyType,
- enableTerminal: sshData.enableTerminal,
- enableTunnel: sshData.enableTunnel,
- tunnelConnections: sshData.tunnelConnections,
- jumpHosts: sshData.jumpHosts,
- enableFileManager: sshData.enableFileManager,
- defaultPath: sshData.defaultPath,
- autostartPassword: sshData.autostartPassword,
- autostartKey: sshData.autostartKey,
- autostartKeyPassword: sshData.autostartKeyPassword,
- forceKeyboardInteractive: sshData.forceKeyboardInteractive,
- statsConfig: sshData.statsConfig,
- terminalConfig: sshData.terminalConfig,
- sudoPassword: sshData.sudoPassword,
- createdAt: sshData.createdAt,
- updatedAt: sshData.updatedAt,
- credentialId: sshData.credentialId,
- overrideCredentialUsername: sshData.overrideCredentialUsername,
- quickActions: sshData.quickActions,
- notes: sshData.notes,
- enableDocker: sshData.enableDocker,
- showTerminalInSidebar: sshData.showTerminalInSidebar,
- showFileManagerInSidebar: sshData.showFileManagerInSidebar,
- showTunnelInSidebar: sshData.showTunnelInSidebar,
- showDockerInSidebar: sshData.showDockerInSidebar,
- showServerStatsInSidebar: sshData.showServerStatsInSidebar,
- useSocks5: sshData.useSocks5,
- socks5Host: sshData.socks5Host,
- socks5Port: sshData.socks5Port,
- socks5Username: sshData.socks5Username,
- socks5Password: sshData.socks5Password,
- socks5ProxyChain: sshData.socks5ProxyChain,
+ id: hosts.id,
+ userId: hosts.userId,
+ connectionType: hosts.connectionType,
+ name: hosts.name,
+ ip: hosts.ip,
+ port: hosts.port,
+ username: hosts.username,
+ folder: hosts.folder,
+ tags: hosts.tags,
+ pin: hosts.pin,
+ authType: hosts.authType,
+ password: hosts.password,
+ key: hosts.key,
+ keyPassword: hosts.keyPassword,
+ keyType: hosts.keyType,
+ enableTerminal: hosts.enableTerminal,
+ enableTunnel: hosts.enableTunnel,
+ tunnelConnections: hosts.tunnelConnections,
+ jumpHosts: hosts.jumpHosts,
+ enableFileManager: hosts.enableFileManager,
+ defaultPath: hosts.defaultPath,
+ autostartPassword: hosts.autostartPassword,
+ autostartKey: hosts.autostartKey,
+ autostartKeyPassword: hosts.autostartKeyPassword,
+ forceKeyboardInteractive: hosts.forceKeyboardInteractive,
+ statsConfig: hosts.statsConfig,
+ terminalConfig: hosts.terminalConfig,
+ sudoPassword: hosts.sudoPassword,
+ createdAt: hosts.createdAt,
+ updatedAt: hosts.updatedAt,
+ credentialId: hosts.credentialId,
+ overrideCredentialUsername: hosts.overrideCredentialUsername,
+ quickActions: hosts.quickActions,
+ notes: hosts.notes,
+ enableDocker: hosts.enableDocker,
+ showTerminalInSidebar: hosts.showTerminalInSidebar,
+ showFileManagerInSidebar: hosts.showFileManagerInSidebar,
+ showTunnelInSidebar: hosts.showTunnelInSidebar,
+ showDockerInSidebar: hosts.showDockerInSidebar,
+ showServerStatsInSidebar: hosts.showServerStatsInSidebar,
+ useSocks5: hosts.useSocks5,
+ socks5Host: hosts.socks5Host,
+ socks5Port: hosts.socks5Port,
+ socks5Username: hosts.socks5Username,
+ socks5Password: hosts.socks5Password,
+ socks5ProxyChain: hosts.socks5ProxyChain,
+ domain: hosts.domain,
+ security: hosts.security,
+ ignoreCert: hosts.ignoreCert,
+ guacamoleConfig: hosts.guacamoleConfig,
- ownerId: sshData.userId,
- isShared: sql`${hostAccess.id} IS NOT NULL AND ${sshData.userId} != ${userId}`,
+ ownerId: hosts.userId,
+ isShared: sql`${hostAccess.id} IS NOT NULL AND ${hosts.userId} != ${userId}`,
permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt,
})
- .from(sshData)
+ .from(hosts)
.leftJoin(
hostAccess,
and(
- eq(hostAccess.hostId, sshData.id),
+ eq(hostAccess.hostId, hosts.id),
or(
eq(hostAccess.userId, userId),
roleIds.length > 0
@@ -1253,7 +1308,7 @@ router.get(
)
.where(
or(
- eq(sshData.userId, userId),
+ eq(hosts.userId, userId),
and(
eq(hostAccess.userId, userId),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
@@ -1361,10 +1416,14 @@ router.get(
return res.status(400).json({ error: "Invalid userId or hostId" });
}
try {
- const data = await db
- .select()
- .from(sshData)
- .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
+ const data = await SimpleDBOps.select(
+ db
+ .select()
+ .from(hosts)
+ .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))),
+ "ssh_data",
+ userId,
+ );
if (data.length === 0) {
sshLogger.warn("SSH host not found", {
@@ -1429,37 +1488,36 @@ router.get(
}
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
db
.select()
- .from(sshData)
- .where(
- and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)),
- ),
+ .from(hosts)
+ .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
- if (hosts.length === 0) {
+ if (hostResults.length === 0) {
return res.status(404).json({ error: "SSH host not found" });
}
- const host = hosts[0];
+ const host = hostResults[0];
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
- const exportData = {
+ const exportedConnectionType =
+ (resolvedHost.connectionType as string) || "ssh";
+ const isRemoteDesktop = ["rdp", "vnc", "telnet"].includes(
+ exportedConnectionType,
+ );
+
+ const baseExportData = {
+ connectionType: exportedConnectionType,
name: resolvedHost.name,
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
- authType: resolvedHost.authType,
password: resolvedHost.password || null,
- key: resolvedHost.key || null,
- keyPassword: resolvedHost.keyPassword || null,
- keyType: resolvedHost.keyType || null,
- credentialId: resolvedHost.credentialId || null,
- overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
@@ -1467,44 +1525,68 @@ router.get(
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
- enableTerminal: !!resolvedHost.enableTerminal,
- enableTunnel: !!resolvedHost.enableTunnel,
- enableFileManager: !!resolvedHost.enableFileManager,
- enableDocker: !!resolvedHost.enableDocker,
- showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
- showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
- showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
- showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
- showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
- defaultPath: resolvedHost.defaultPath,
- sudoPassword: resolvedHost.sudoPassword || null,
- tunnelConnections: resolvedHost.tunnelConnections
- ? JSON.parse(resolvedHost.tunnelConnections as string)
- : [],
- jumpHosts: resolvedHost.jumpHosts
- ? JSON.parse(resolvedHost.jumpHosts as string)
- : null,
- quickActions: resolvedHost.quickActions
- ? JSON.parse(resolvedHost.quickActions as string)
- : null,
- statsConfig: resolvedHost.statsConfig
- ? JSON.parse(resolvedHost.statsConfig as string)
- : null,
- terminalConfig: resolvedHost.terminalConfig
- ? JSON.parse(resolvedHost.terminalConfig as string)
- : null,
- forceKeyboardInteractive:
- resolvedHost.forceKeyboardInteractive === "true",
- useSocks5: !!resolvedHost.useSocks5,
- socks5Host: resolvedHost.socks5Host || null,
- socks5Port: resolvedHost.socks5Port || null,
- socks5Username: resolvedHost.socks5Username || null,
- socks5Password: resolvedHost.socks5Password || null,
- socks5ProxyChain: resolvedHost.socks5ProxyChain
- ? JSON.parse(resolvedHost.socks5ProxyChain as string)
- : null,
};
+ const exportData = isRemoteDesktop
+ ? {
+ ...baseExportData,
+ domain: resolvedHost.domain || null,
+ security: resolvedHost.security || null,
+ ignoreCert: !!resolvedHost.ignoreCert,
+ guacamoleConfig: resolvedHost.guacamoleConfig
+ ? JSON.parse(resolvedHost.guacamoleConfig as string)
+ : null,
+ }
+ : {
+ ...baseExportData,
+ authType: resolvedHost.authType,
+ key: resolvedHost.key || null,
+ keyPassword: resolvedHost.keyPassword || null,
+ keyType: resolvedHost.keyType || null,
+ credentialId: resolvedHost.credentialId || null,
+ overrideCredentialUsername:
+ !!resolvedHost.overrideCredentialUsername,
+ enableTerminal: !!resolvedHost.enableTerminal,
+ enableTunnel: !!resolvedHost.enableTunnel,
+ enableFileManager: !!resolvedHost.enableFileManager,
+ enableDocker: !!resolvedHost.enableDocker,
+ showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
+ showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
+ showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
+ showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
+ showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
+ defaultPath: resolvedHost.defaultPath,
+ sudoPassword: resolvedHost.sudoPassword || null,
+ tunnelConnections: resolvedHost.tunnelConnections
+ ? JSON.parse(resolvedHost.tunnelConnections as string)
+ : [],
+ jumpHosts: resolvedHost.jumpHosts
+ ? JSON.parse(resolvedHost.jumpHosts as string)
+ : null,
+ quickActions: resolvedHost.quickActions
+ ? JSON.parse(resolvedHost.quickActions as string)
+ : null,
+ statsConfig: resolvedHost.statsConfig
+ ? JSON.parse(resolvedHost.statsConfig as string)
+ : null,
+ dockerConfig: resolvedHost.dockerConfig
+ ? JSON.parse(resolvedHost.dockerConfig as string)
+ : null,
+ terminalConfig: resolvedHost.terminalConfig
+ ? JSON.parse(resolvedHost.terminalConfig as string)
+ : null,
+ forceKeyboardInteractive:
+ resolvedHost.forceKeyboardInteractive === "true",
+ useSocks5: !!resolvedHost.useSocks5,
+ socks5Host: resolvedHost.socks5Host || null,
+ socks5Port: resolvedHost.socks5Port || null,
+ socks5Username: resolvedHost.socks5Username || null,
+ socks5Password: resolvedHost.socks5Password || null,
+ socks5ProxyChain: resolvedHost.socks5ProxyChain
+ ? JSON.parse(resolvedHost.socks5ProxyChain as string)
+ : null,
+ };
+
sshLogger.success("Host exported with decrypted credentials", {
operation: "host_export",
hostId: parseInt(hostId),
@@ -1573,8 +1655,8 @@ router.delete(
try {
const hostToDelete = await db
.select()
- .from(sshData)
- .where(and(eq(sshData.id, Number(hostId)), eq(sshData.userId, userId)));
+ .from(hosts)
+ .where(and(eq(hosts.id, Number(hostId)), eq(hosts.userId, userId)));
if (hostToDelete.length === 0) {
sshLogger.warn("SSH host not found for deletion", {
@@ -1618,8 +1700,8 @@ router.delete(
.where(eq(sessionRecordings.hostId, numericHostId));
await db
- .delete(sshData)
- .where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
+ .delete(hosts)
+ .where(and(eq(hosts.id, numericHostId), eq(hosts.userId, userId)));
databaseLogger.success("SSH host deleted", {
operation: "host_delete_success",
@@ -1629,7 +1711,7 @@ router.delete(
try {
const axios = (await import("axios")).default;
- const statsPort = process.env.STATS_PORT || 30005;
+ const statsPort = 30005;
await axios.post(
`http://localhost:${statsPort}/host-deleted`,
{ hostId: numericHostId },
@@ -2522,9 +2604,9 @@ router.put(
try {
const updatedHosts = await SimpleDBOps.update(
- sshData,
+ hosts,
"ssh_data",
- and(eq(sshData.userId, userId), eq(sshData.folder, oldName)),
+ and(eq(hosts.userId, userId), eq(hosts.folder, oldName)),
{
folder: newName,
updatedAt: new Date().toISOString(),
@@ -2748,8 +2830,8 @@ router.delete(
try {
const hostsToDelete = await db
.select()
- .from(sshData)
- .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
+ .from(hosts)
+ .where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
if (hostsToDelete.length === 0) {
return res.json({
@@ -2793,8 +2875,8 @@ router.delete(
}
await db
- .delete(sshData)
- .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
+ .delete(hosts)
+ .where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
await db
.delete(sshFolders)
@@ -2806,7 +2888,7 @@ router.delete(
try {
const axios = (await import("axios")).default;
- const statsPort = process.env.STATS_PORT || 30005;
+ const statsPort = 30005;
for (const host of hostsToDelete) {
try {
await axios.post(
@@ -2935,9 +3017,9 @@ router.patch(
try {
const ownedHosts = await db
- .select({ id: sshData.id, statsConfig: sshData.statsConfig })
- .from(sshData)
- .where(and(inArray(sshData.id, hostIds), eq(sshData.userId, userId)));
+ .select({ id: hosts.id, statsConfig: hosts.statsConfig })
+ .from(hosts)
+ .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)));
const ownedIds = ownedHosts.map((h) => h.id);
const unauthorizedIds = hostIds.filter(
@@ -2968,11 +3050,9 @@ router.patch(
if (Object.keys(simpleUpdates).length > 0) {
await db
- .update(sshData)
+ .update(hosts)
.set(simpleUpdates)
- .where(
- and(inArray(sshData.id, ownedIds), eq(sshData.userId, userId)),
- );
+ .where(and(inArray(hosts.id, ownedIds), eq(hosts.userId, userId)));
}
if (updates.statsConfig && typeof updates.statsConfig === "object") {
@@ -2983,9 +3063,9 @@ router.patch(
: {};
const merged = { ...existing, ...updates.statsConfig };
await db
- .update(sshData)
+ .update(hosts)
.set({ statsConfig: JSON.stringify(merged) })
- .where(and(eq(sshData.id, host.id), eq(sshData.userId, userId)));
+ .where(and(eq(hosts.id, host.id), eq(hosts.userId, userId)));
} catch (e) {
errors.push(`Failed to update statsConfig for host ${host.id}`);
}
@@ -3011,15 +3091,15 @@ router.post(
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
- const { hosts, overwrite } = req.body;
+ const { hosts: hostsToImport, overwrite } = req.body;
- if (!Array.isArray(hosts) || hosts.length === 0) {
+ if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
return res
.status(400)
.json({ error: "Hosts array is required and must not be empty" });
}
- if (hosts.length > 100) {
+ if (hostsToImport.length > 100) {
return res
.status(400)
.json({ error: "Maximum 100 hosts allowed per import" });
@@ -3037,7 +3117,7 @@ router.post(
if (overwrite) {
try {
const allHosts = await SimpleDBOps.select>(
- db.select().from(sshData).where(eq(sshData.userId, userId)),
+ db.select().from(hosts).where(eq(hosts.userId, userId)),
"ssh_data",
userId,
);
@@ -3051,23 +3131,34 @@ router.post(
}
}
- for (let i = 0; i < hosts.length; i++) {
- const hostData = hosts[i];
+ for (let i = 0; i < hostsToImport.length; i++) {
+ const hostData = hostsToImport[i];
try {
- if (
- !isNonEmptyString(hostData.ip) ||
- !isValidPort(hostData.port) ||
- !isNonEmptyString(hostData.username)
- ) {
+ const effectiveConnectionType = hostData.connectionType || "ssh";
+
+ if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
results.failed++;
results.errors.push(
- `Host ${i + 1}: Missing required fields (ip, port, username)`,
+ `Host ${i + 1}: Missing required fields (ip, port)`,
);
continue;
}
if (
+ effectiveConnectionType === "ssh" &&
+ !isNonEmptyString(hostData.username)
+ ) {
+ results.failed++;
+ results.errors.push(
+ `Host ${i + 1}: Username required for SSH connections`,
+ );
+ continue;
+ }
+
+ if (
+ effectiveConnectionType === "ssh" &&
+ hostData.authType &&
!["password", "key", "credential", "none", "opkssh"].includes(
hostData.authType,
)
@@ -3080,6 +3171,7 @@ router.post(
}
if (
+ effectiveConnectionType === "ssh" &&
hostData.authType === "password" &&
!isNonEmptyString(hostData.password)
) {
@@ -3090,7 +3182,11 @@ router.post(
continue;
}
- if (hostData.authType === "key" && !isNonEmptyString(hostData.key)) {
+ if (
+ effectiveConnectionType === "ssh" &&
+ hostData.authType === "key" &&
+ !isNonEmptyString(hostData.key)
+ ) {
results.failed++;
results.errors.push(
`Host ${i + 1}: Key required for key authentication`,
@@ -3098,7 +3194,11 @@ router.post(
continue;
}
- if (hostData.authType === "credential" && !hostData.credentialId) {
+ if (
+ effectiveConnectionType === "ssh" &&
+ hostData.authType === "credential" &&
+ !hostData.credentialId
+ ) {
results.failed++;
results.errors.push(
`Host ${i + 1}: credentialId required for credential authentication`,
@@ -3108,21 +3208,13 @@ router.post(
const sshDataObj: Record = {
userId: userId,
- name: hostData.name || `${hostData.username}@${hostData.ip}`,
+ connectionType: effectiveConnectionType,
+ name: hostData.name || `${hostData.username || ""}@${hostData.ip}`,
folder: hostData.folder || "Default",
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
ip: hostData.ip,
port: hostData.port,
- username: hostData.username,
- password: hostData.authType === "password" ? hostData.password : null,
- authType: hostData.authType,
- credentialId:
- hostData.authType === "credential" ? hostData.credentialId : null,
- key: hostData.authType === "key" ? hostData.key : null,
- keyPassword:
- hostData.authType === "key" ? hostData.keyPassword || null : null,
- keyType:
- hostData.authType === "key" ? hostData.keyType || "auto" : null,
+ username: hostData.username || null,
pin: hostData.pin || false,
enableTerminal: hostData.enableTerminal !== false,
enableTunnel: hostData.enableTunnel !== false,
@@ -3147,6 +3239,9 @@ router.post(
statsConfig: hostData.statsConfig
? JSON.stringify(hostData.statsConfig)
: null,
+ dockerConfig: hostData.dockerConfig
+ ? JSON.stringify(hostData.dockerConfig)
+ : null,
terminalConfig: hostData.terminalConfig
? JSON.stringify(hostData.terminalConfig)
: null,
@@ -3168,21 +3263,51 @@ router.post(
updatedAt: new Date().toISOString(),
};
+ if (effectiveConnectionType !== "ssh") {
+ sshDataObj.password = hostData.password || null;
+ sshDataObj.authType = "password";
+ sshDataObj.credentialId = null;
+ sshDataObj.key = null;
+ sshDataObj.keyPassword = null;
+ sshDataObj.keyType = null;
+ sshDataObj.domain = hostData.domain || null;
+ sshDataObj.security = hostData.security || null;
+ sshDataObj.ignoreCert = hostData.ignoreCert ? 1 : 0;
+ sshDataObj.guacamoleConfig = hostData.guacamoleConfig
+ ? JSON.stringify(hostData.guacamoleConfig)
+ : null;
+ } else {
+ sshDataObj.password =
+ hostData.authType === "password" ? hostData.password : null;
+ sshDataObj.authType = hostData.authType || "password";
+ sshDataObj.credentialId =
+ hostData.authType === "credential" ? hostData.credentialId : null;
+ sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
+ sshDataObj.keyPassword =
+ hostData.authType === "key" ? hostData.keyPassword || null : null;
+ sshDataObj.keyType =
+ hostData.authType === "key" ? hostData.keyType || "auto" : null;
+ sshDataObj.domain = null;
+ sshDataObj.security = null;
+ sshDataObj.ignoreCert = 0;
+ sshDataObj.guacamoleConfig = null;
+ }
+
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing) {
await SimpleDBOps.update(
- sshData,
+ hosts,
"ssh_data",
- eq(sshData.id, existing.id),
+ eq(hosts.id, existing.id),
sshDataObj,
userId,
);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
- await SimpleDBOps.insert(sshData, "ssh_data", sshDataObj, userId);
+ await SimpleDBOps.insert(hosts, "ssh_data", sshDataObj, userId);
results.success++;
}
} catch (error) {
@@ -3204,6 +3329,104 @@ router.post(
},
);
+/**
+ * @openapi
+ * /ssh/folders/{folderName}/hosts:
+ * delete:
+ * summary: Delete all hosts in a folder
+ * description: Deletes all hosts within a specific folder.
+ * tags:
+ * - SSH
+ * parameters:
+ * - in: path
+ * name: folderName
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: All hosts deleted successfully.
+ * 400:
+ * description: Invalid folder name.
+ * 500:
+ * description: Failed to delete hosts.
+ */
+router.delete(
+ "/folders/:folderName/hosts",
+ authenticateJWT,
+ requireDataAccess,
+ async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const folderName = decodeURIComponent(
+ Array.isArray(req.params.folderName)
+ ? req.params.folderName[0]
+ : req.params.folderName,
+ );
+
+ if (!folderName) {
+ return res.status(400).json({ error: "Folder name is required" });
+ }
+
+ try {
+ const hostsToDelete = await db
+ .select({ id: hosts.id })
+ .from(hosts)
+ .where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
+
+ if (hostsToDelete.length === 0) {
+ return res.json({ deletedCount: 0 });
+ }
+
+ const hostIds = hostsToDelete.map((h) => h.id);
+
+ for (const hostId of hostIds) {
+ await db
+ .delete(fileManagerRecent)
+ .where(eq(fileManagerRecent.hostId, hostId));
+ await db
+ .delete(fileManagerPinned)
+ .where(eq(fileManagerPinned.hostId, hostId));
+ await db
+ .delete(fileManagerShortcuts)
+ .where(eq(fileManagerShortcuts.hostId, hostId));
+ await db
+ .delete(commandHistory)
+ .where(eq(commandHistory.hostId, hostId));
+ await db
+ .delete(sshCredentialUsage)
+ .where(eq(sshCredentialUsage.hostId, hostId));
+ await db
+ .delete(recentActivity)
+ .where(eq(recentActivity.hostId, hostId));
+ await db.delete(hostAccess).where(eq(hostAccess.hostId, hostId));
+ await db
+ .delete(sessionRecordings)
+ .where(eq(sessionRecordings.hostId, hostId));
+ }
+
+ await db
+ .delete(hosts)
+ .where(and(eq(hosts.userId, userId), eq(hosts.folder, folderName)));
+
+ databaseLogger.success("All hosts in folder deleted", {
+ operation: "delete_folder_hosts",
+ userId,
+ folderName,
+ deletedCount: hostsToDelete.length,
+ });
+
+ res.json({ deletedCount: hostsToDelete.length });
+ } catch (error) {
+ sshLogger.error("Failed to delete hosts in folder", error, {
+ operation: "delete_folder_hosts",
+ userId,
+ folderName,
+ });
+ res.status(500).json({ error: "Failed to delete hosts in folder" });
+ }
+ },
+);
+
/**
* @openapi
* /ssh/autostart/enable:
@@ -3270,8 +3493,8 @@ router.post(
const sshConfig = await db
.select()
- .from(sshData)
- .where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
+ .from(hosts)
+ .where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
if (sshConfig.length === 0) {
sshLogger.warn("SSH config not found for autostart enable", {
@@ -3309,8 +3532,8 @@ router.post(
) {
const endpointHosts = await db
.select()
- .from(sshData)
- .where(eq(sshData.userId, userId));
+ .from(hosts)
+ .where(eq(hosts.userId, userId));
const endpointHost = endpointHosts.find(
(h) =>
@@ -3349,14 +3572,14 @@ router.post(
}
await db
- .update(sshData)
+ .update(hosts)
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
tunnelConnections: updatedTunnelConnections,
})
- .where(eq(sshData.id, sshConfigId));
+ .where(eq(hosts.id, sshConfigId));
try {
await DatabaseSaveTrigger.triggerSave();
@@ -3429,13 +3652,13 @@ router.delete(
try {
await db
- .update(sshData)
+ .update(hosts)
.set({
autostartPassword: null,
autostartKey: null,
autostartKeyPassword: null,
})
- .where(and(eq(sshData.id, sshConfigId), eq(sshData.userId, userId)));
+ .where(and(eq(hosts.id, sshConfigId), eq(hosts.userId, userId)));
res.json({
message: "AutoStart disabled successfully",
@@ -3475,13 +3698,13 @@ router.get(
try {
const autostartConfigs = await db
.select()
- .from(sshData)
+ .from(hosts)
.where(
and(
- eq(sshData.userId, userId),
+ eq(hosts.userId, userId),
or(
- isNotNull(sshData.autostartPassword),
- isNotNull(sshData.autostartKey),
+ isNotNull(hosts.autostartPassword),
+ isNotNull(hosts.autostartKey),
),
),
);
diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts
index 98ba1ba0..62f41aa6 100644
--- a/src/backend/database/routes/rbac.ts
+++ b/src/backend/database/routes/rbac.ts
@@ -3,7 +3,7 @@ import express from "express";
import { db } from "../db/index.js";
import {
hostAccess,
- sshData,
+ hosts,
users,
roles,
userRoles,
@@ -111,8 +111,8 @@ router.post(
const host = await db
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.limit(1);
if (host.length === 0) {
@@ -201,9 +201,8 @@ router.post(
.delete(sharedCredentials)
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
- const { SharedCredentialManager } = await import(
- "../../utils/shared-credential-manager.js"
- );
+ const { SharedCredentialManager } =
+ await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
await sharedCredManager.createSharedCredentialForUser(
@@ -244,9 +243,8 @@ router.post(
expiresAt,
});
- const { SharedCredentialManager } = await import(
- "../../utils/shared-credential-manager.js"
- );
+ const { SharedCredentialManager } =
+ await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (targetType === "user") {
@@ -336,8 +334,8 @@ router.delete(
try {
const host = await db
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.limit(1);
if (host.length === 0) {
@@ -404,8 +402,8 @@ router.get(
try {
const host = await db
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.limit(1);
if (host.length === 0) {
@@ -484,21 +482,21 @@ router.get(
const sharedHosts = await db
.select({
- id: sshData.id,
- name: sshData.name,
- ip: sshData.ip,
- port: sshData.port,
- username: sshData.username,
- folder: sshData.folder,
- tags: sshData.tags,
+ id: hosts.id,
+ name: hosts.name,
+ ip: hosts.ip,
+ port: hosts.port,
+ username: hosts.username,
+ folder: hosts.folder,
+ tags: hosts.tags,
permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt,
grantedBy: hostAccess.grantedBy,
ownerUsername: users.username,
})
.from(hostAccess)
- .innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
- .innerJoin(users, eq(sshData.userId, users.id))
+ .innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
+ .innerJoin(users, eq(hosts.userId, users.id))
.where(
and(
eq(hostAccess.userId, userId),
@@ -978,12 +976,11 @@ router.post(
const hostsSharedWithRole = await db
.select()
.from(hostAccess)
- .innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
+ .innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
.where(eq(hostAccess.roleId, roleId));
- const { SharedCredentialManager } = await import(
- "../../utils/shared-credential-manager.js"
- );
+ const { SharedCredentialManager } =
+ await import("../../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
for (const { host_access, ssh_data } of hostsSharedWithRole) {
diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts
index 6870f6a5..97d2bf64 100644
--- a/src/backend/database/routes/snippets.ts
+++ b/src/backend/database/routes/snippets.ts
@@ -632,17 +632,15 @@ router.post(
const snippet = snippetResult[0];
const { Client } = await import("ssh2");
- const { sshData, sshCredentials } = await import("../db/schema.js");
+ const { hosts, sshCredentials } = await import("../db/schema.js");
const { SimpleDBOps } = await import("../../utils/simple-db-ops.js");
const hostResult = await SimpleDBOps.select(
db
.select()
- .from(sshData)
- .where(
- and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
- ),
+ .from(hosts)
+ .where(and(eq(hosts.id, parseInt(hostId)), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts
index 3e19b555..f1282b0f 100644
--- a/src/backend/database/routes/users.ts
+++ b/src/backend/database/routes/users.ts
@@ -1,11 +1,12 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import express from "express";
+import { restartGuacServer } from "../../guacamole/guacamole-server.js";
import crypto from "crypto";
import { db } from "../db/index.js";
import {
users,
sessions,
- sshData,
+ hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
@@ -265,7 +266,7 @@ async function deleteUserAndRelatedData(userId: string): Promise {
await db.delete(commandHistory).where(eq(commandHistory.userId, userId));
- await db.delete(sshData).where(eq(sshData.userId, userId));
+ await db.delete(hosts).where(eq(hosts.userId, userId));
await db.delete(sshCredentials).where(eq(sshCredentials.userId, userId));
await db.delete(networkTopology).where(eq(networkTopology.userId, userId));
@@ -789,6 +790,12 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
* description: Returns the OIDC authorization URL.
* tags:
* - Users
+ * parameters:
+ * - in: query
+ * name: rememberMe
+ * schema:
+ * type: boolean
+ * description: Whether to extend the session to 30 days instead of 2 hours.
* responses:
* 200:
* description: OIDC authorization URL.
@@ -799,29 +806,10 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
*/
router.get("/oidc/authorize", async (req, res) => {
try {
+ const { rememberMe } = req.query;
const origin = getRequestOriginWithForceHTTPS(req);
const backendCallbackUri = `${origin}/users/oidc/callback`;
- authLogger.info("OIDC authorize request headers", {
- protocol: req.protocol,
- host: req.get("Host"),
- origin: req.get("Origin"),
- referer: req.get("Referer"),
- "x-forwarded-proto": req.get("X-Forwarded-Proto"),
- "x-forwarded-host": req.get("X-Forwarded-Host"),
- "x-forwarded-port": req.get("X-Forwarded-Port"),
- secure: req.secure,
- calculatedOrigin: origin,
- backendCallbackUri: backendCallbackUri,
- });
-
- authLogger.info(
- `\n${"=".repeat(68)}\n` +
- ` OIDC CALLBACK URL - Register this in your OAuth provider:\n` +
- ` ${backendCallbackUri}\n` +
- `${"=".repeat(68)}`,
- );
-
const envConfig = getOIDCConfigFromEnv();
let config;
@@ -860,12 +848,12 @@ router.get("/oidc/authorize", async (req, res) => {
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.run(`oidc_frontend_origin_${state}`, frontendOrigin);
- authLogger.info("OIDC authorization initiated", {
- operation: "oidc_authorize",
- backendCallbackUri,
- frontendOrigin,
- referer,
- });
+ db.$client
+ .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
+ .run(
+ `oidc_remember_me_${state}`,
+ rememberMe === "true" ? "true" : "false",
+ );
const authUrl = new URL(config.authorization_url);
authUrl.searchParams.set("client_id", config.client_id);
@@ -898,10 +886,6 @@ router.get("/oidc/authorize", async (req, res) => {
*/
router.get("/oidc/callback", async (req, res) => {
const { code, state } = req.query;
- authLogger.info("OIDC login callback received", {
- operation: "oidc_login_request",
- state,
- });
if (!isNonEmptyString(code) || !isNonEmptyString(state)) {
return res.status(400).json({ error: "Code and state are required" });
@@ -913,6 +897,9 @@ router.get("/oidc/callback", async (req, res) => {
const storedFrontendOriginRow = db.$client
.prepare("SELECT value FROM settings WHERE key = ?")
.get(`oidc_frontend_origin_${state}`);
+ const storedRememberMeRow = db.$client
+ .prepare("SELECT value FROM settings WHERE key = ?")
+ .get(`oidc_remember_me_${state}`);
if (!storedBackendCallbackRow || !storedFrontendOriginRow) {
return res
@@ -925,6 +912,8 @@ router.get("/oidc/callback", async (req, res) => {
).value as string;
const frontendOrigin = (storedFrontendOriginRow as Record)
.value as string;
+ const storedRememberMe =
+ (storedRememberMeRow as Record | null)?.value === "true";
try {
const storedNonce = db.$client
@@ -951,12 +940,6 @@ router.get("/oidc/callback", async (req, res) => {
);
}
- authLogger.info("OIDC token exchange attempt", {
- operation: "oidc_token_exchange",
- backendCallbackUri,
- frontendOrigin,
- });
-
const tokenResponse = await fetch(config.token_url, {
method: "POST",
headers: {
@@ -998,6 +981,9 @@ router.get("/oidc/callback", async (req, res) => {
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
.run(`oidc_frontend_origin_${state}`);
+ db.$client
+ .prepare("DELETE FROM settings WHERE key = ?")
+ .run(`oidc_remember_me_${state}`);
let userInfo: Record = null;
const userInfoUrls: string[] = [];
@@ -1320,6 +1306,7 @@ router.get("/oidc/callback", async (req, res) => {
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
+ rememberMe: storedRememberMe,
});
authLogger.success("OIDC login successful", {
@@ -1334,7 +1321,9 @@ router.get("/oidc/callback", async (req, res) => {
const maxAge =
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
? 30 * 24 * 60 * 60 * 1000
- : 2 * 60 * 60 * 1000;
+ : storedRememberMe
+ ? 30 * 24 * 60 * 60 * 1000
+ : 2 * 60 * 60 * 1000;
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
@@ -2488,7 +2477,7 @@ router.post("/complete-reset", async (req, res) => {
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
await db.delete(snippets).where(eq(snippets.userId, userId));
- await db.delete(sshData).where(eq(sshData.userId, userId));
+ await db.delete(hosts).where(eq(hosts.userId, userId));
await db
.delete(sshCredentials)
.where(eq(sshCredentials.userId, userId));
@@ -4126,4 +4115,115 @@ router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => {
}
});
+/**
+ * @openapi
+ * /users/guacamole-settings:
+ * get:
+ * summary: Get Guacamole settings
+ * description: Returns current guacd enabled status and host:port URL. No authentication required.
+ * tags:
+ * - Users
+ * responses:
+ * 200:
+ * description: Guacamole settings.
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * enabled:
+ * type: boolean
+ * url:
+ * type: string
+ * 500:
+ * description: Failed to get guacamole settings.
+ */
+router.get("/guacamole-settings", async (req, res) => {
+ try {
+ const enabledRow = db.$client
+ .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
+ .get() as { value: string } | undefined;
+ const urlRow = db.$client
+ .prepare("SELECT value FROM settings WHERE key = 'guac_url'")
+ .get() as { value: string } | undefined;
+ res.json({
+ enabled: enabledRow ? enabledRow.value !== "false" : true,
+ url: urlRow ? urlRow.value : "guacd:4822",
+ });
+ } catch (err) {
+ authLogger.error("Failed to get guacamole settings", err);
+ res.status(500).json({ error: "Failed to get guacamole settings" });
+ }
+});
+
+/**
+ * @openapi
+ * /users/guacamole-settings:
+ * patch:
+ * summary: Update Guacamole settings
+ * description: Admin-only. Updates guacd enabled status and/or host:port URL.
+ * tags:
+ * - Users
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * enabled:
+ * type: boolean
+ * url:
+ * type: string
+ * responses:
+ * 200:
+ * description: Guacamole settings updated.
+ * 403:
+ * description: Not authorized.
+ * 500:
+ * description: Failed to update guacamole settings.
+ */
+router.patch("/guacamole-settings", authenticateJWT, async (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ try {
+ const user = await db.select().from(users).where(eq(users.id, userId));
+ if (!user || user.length === 0 || !user[0].isAdmin) {
+ return res.status(403).json({ error: "Not authorized" });
+ }
+ const { enabled, url } = req.body;
+ if (typeof enabled === "boolean") {
+ db.$client
+ .prepare(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_enabled', ?)",
+ )
+ .run(enabled ? "true" : "false");
+ }
+ if (typeof url === "string") {
+ db.$client
+ .prepare(
+ "INSERT OR REPLACE INTO settings (key, value) VALUES ('guac_url', ?)",
+ )
+ .run(url);
+ try {
+ await restartGuacServer();
+ } catch (err) {
+ authLogger.error("Failed to restart guac server after URL update", err);
+ }
+ }
+ const enabledRow = db.$client
+ .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
+ .get() as { value: string } | undefined;
+ const urlRow = db.$client
+ .prepare("SELECT value FROM settings WHERE key = 'guac_url'")
+ .get() as { value: string } | undefined;
+ res.json({
+ enabled: enabledRow ? enabledRow.value !== "false" : true,
+ url: urlRow ? urlRow.value : "guacd:4822",
+ });
+ } catch (err) {
+ authLogger.error("Failed to update guacamole settings", err);
+ res.status(500).json({ error: "Failed to update guacamole settings" });
+ }
+});
+
export default router;
diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts
new file mode 100644
index 00000000..6dae1a72
--- /dev/null
+++ b/src/backend/guacamole/guacamole-server.ts
@@ -0,0 +1,153 @@
+import GuacamoleLite from "guacamole-lite";
+import { parse as parseUrl } from "url";
+import { guacLogger } from "../utils/logger.js";
+import { AuthManager } from "../utils/auth-manager.js";
+import { GuacamoleTokenService } from "./token-service.js";
+import { getDb } from "../database/db/index.js";
+import type { IncomingMessage } from "http";
+
+const authManager = AuthManager.getInstance();
+const tokenService = GuacamoleTokenService.getInstance();
+
+function parseGuacUrl(url: string): { host: string; port: number } {
+ const parts = url.split(":");
+ return {
+ host: parts[0] || "localhost",
+ port: parseInt(parts[1] || "4822", 10),
+ };
+}
+
+function readGuacdOptions(): { host: string; port: number } {
+ let host = process.env.GUACD_HOST || "localhost";
+ let port = parseInt(process.env.GUACD_PORT || "4822", 10);
+ try {
+ const db = getDb();
+ const urlRow = db.$client
+ .prepare("SELECT value FROM settings WHERE key = 'guac_url'")
+ .get() as { value: string } | undefined;
+ if (urlRow?.value) {
+ const parsed = parseGuacUrl(urlRow.value);
+ host = parsed.host;
+ port = parsed.port;
+ }
+ } catch {
+ // DB not available yet, use env var defaults
+ }
+ return { host, port };
+}
+
+const GUAC_WS_PORT = 30008;
+
+const websocketOptions = {
+ port: GUAC_WS_PORT,
+};
+
+const clientOptions = {
+ crypt: {
+ cypher: "AES-256-CBC",
+ key: tokenService.getEncryptionKey(),
+ },
+ log: {
+ level: "ERRORS",
+ stdLog: (...args: unknown[]) => {
+ guacLogger.info(args.join(" "));
+ },
+ errorLog: (...args: unknown[]) => {
+ guacLogger.error(args.join(" "));
+ },
+ },
+ allowedUnencryptedConnectionSettings: {
+ rdp: ["width", "height", "dpi"],
+ vnc: ["width", "height", "dpi"],
+ telnet: ["width", "height"],
+ },
+ connectionDefaultSettings: {
+ rdp: {
+ security: "any",
+ "ignore-cert": true,
+ "enable-wallpaper": false,
+ "enable-font-smoothing": true,
+ "enable-desktop-composition": false,
+ "disable-audio": false,
+ "enable-drive": false,
+ "resize-method": "display-update",
+ width: 1280,
+ height: 720,
+ dpi: 96,
+ },
+ vnc: {
+ "swap-red-blue": false,
+ cursor: "remote",
+ width: 1280,
+ height: 720,
+ },
+ telnet: {
+ "terminal-type": "xterm-256color",
+ },
+ },
+};
+
+const _origConsoleLog = console.log;
+console.log = (...args: unknown[]) => {
+ const msg = args[0];
+ if (typeof msg === "string" && msg.startsWith("New client connection"))
+ return;
+ _origConsoleLog(...args);
+};
+
+function createGuacServer(): GuacamoleLite {
+ const guacdOptions = readGuacdOptions();
+ const server = new GuacamoleLite(
+ websocketOptions,
+ guacdOptions,
+ clientOptions,
+ );
+
+ server.on(
+ "open",
+ (clientConnection: { connectionSettings?: Record }) => {
+ guacLogger.info("Guacamole connection opened", {
+ operation: "guac_connection_open",
+ type: clientConnection.connectionSettings?.type,
+ });
+ },
+ );
+
+ server.on(
+ "close",
+ (clientConnection: { connectionSettings?: Record }) => {
+ guacLogger.info("Guacamole connection closed", {
+ operation: "guac_connection_close",
+ type: clientConnection.connectionSettings?.type,
+ });
+ },
+ );
+
+ server.on(
+ "error",
+ (
+ clientConnection: { connectionSettings?: Record },
+ error: Error,
+ ) => {
+ guacLogger.error("Guacamole connection error", error, {
+ operation: "guac_connection_error",
+ type: clientConnection.connectionSettings?.type,
+ });
+ },
+ );
+
+ return server;
+}
+
+let guacServer = createGuacServer();
+
+export async function restartGuacServer(): Promise {
+ try {
+ guacServer.close();
+ } catch (err) {
+ guacLogger.error("Error closing guac server during restart", err as Error);
+ }
+ guacServer = createGuacServer();
+}
+
+export { guacServer, tokenService };
diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts
new file mode 100644
index 00000000..ddbf4503
--- /dev/null
+++ b/src/backend/guacamole/routes.ts
@@ -0,0 +1,303 @@
+import express from "express";
+import { GuacamoleTokenService } from "./token-service.js";
+import { guacLogger } from "../utils/logger.js";
+import { AuthManager } from "../utils/auth-manager.js";
+import { PermissionManager } from "../utils/permission-manager.js";
+import { SimpleDBOps } from "../utils/simple-db-ops.js";
+import { getDb } from "../database/db/index.js";
+import { hosts } from "../database/db/schema.js";
+import { eq, and } from "drizzle-orm";
+import type { AuthenticatedRequest } from "../../types/index.js";
+
+const router = express.Router();
+const tokenService = GuacamoleTokenService.getInstance();
+const authManager = AuthManager.getInstance();
+
+router.use(authManager.createAuthMiddleware());
+
+/**
+ * POST /guacamole/token
+ * Generate an encrypted connection token for guacamole-lite
+ *
+ * Body: {
+ * type: "rdp" | "vnc" | "telnet",
+ * hostname: string,
+ * port?: number,
+ * username?: string,
+ * password?: string,
+ * domain?: string,
+ * // Additional protocol-specific options
+ * }
+ */
+router.post("/token", async (req, res) => {
+ try {
+ const userId = (req as AuthenticatedRequest).userId;
+ const { type, hostname, port, username, password, domain, ...options } =
+ req.body;
+
+ if (!type || !hostname) {
+ return res
+ .status(400)
+ .json({ error: "Missing required fields: type and hostname" });
+ }
+
+ if (!["rdp", "vnc", "telnet"].includes(type)) {
+ return res.status(400).json({
+ error: "Invalid connection type. Must be rdp, vnc, or telnet",
+ });
+ }
+
+ let token: string;
+
+ switch (type) {
+ case "rdp":
+ token = tokenService.createRdpToken(
+ hostname,
+ username || "",
+ password || "",
+ {
+ port: port || 3389,
+ domain,
+ ...options,
+ },
+ );
+ break;
+ case "vnc":
+ token = tokenService.createVncToken(hostname, password, {
+ port: port || 5900,
+ ...options,
+ });
+ break;
+ case "telnet":
+ token = tokenService.createTelnetToken(hostname, username, password, {
+ port: port || 23,
+ ...options,
+ });
+ break;
+ default:
+ return res.status(400).json({ error: "Invalid connection type" });
+ }
+
+ res.json({ token });
+ } catch (error) {
+ guacLogger.error("Failed to generate guacamole token", error, {
+ operation: "guac_token_error",
+ });
+ res.status(500).json({ error: "Failed to generate connection token" });
+ }
+});
+
+/**
+ * @openapi
+ * /guacamole/connect-host/{hostId}:
+ * post:
+ * summary: Generate Guacamole connection token from host configuration
+ * description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet
+ * tags:
+ * - Guacamole
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: hostId
+ * required: true
+ * schema:
+ * type: integer
+ * description: Host ID to connect to
+ * responses:
+ * 200:
+ * description: Connection token generated successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * token:
+ * type: string
+ * description: Encrypted connection token
+ * 400:
+ * description: Invalid request or unsupported connection type
+ * 403:
+ * description: Access denied to host
+ * 404:
+ * description: Host not found
+ * 500:
+ * description: Server error
+ */
+router.post(
+ "/connect-host/:hostId",
+ async (req: express.Request, res: express.Response) => {
+ try {
+ const userId = (req as AuthenticatedRequest).userId!;
+ const hostId = parseInt(req.params.hostId, 10);
+
+ if (!hostId || isNaN(hostId)) {
+ return res.status(400).json({ error: "Invalid host ID" });
+ }
+
+ const hostResults = await SimpleDBOps.select(
+ getDb().select().from(hosts).where(eq(hosts.id, hostId)),
+ "ssh_data",
+ userId,
+ );
+
+ if (hostResults.length === 0) {
+ return res.status(404).json({ error: "Host not found" });
+ }
+
+ const host = hostResults[0];
+
+ if (host.userId !== userId) {
+ const permissionManager = PermissionManager.getInstance();
+ const accessInfo = await permissionManager.canAccessHost(
+ userId,
+ hostId,
+ "read",
+ );
+
+ if (!accessInfo.hasAccess) {
+ guacLogger.warn("User attempted to access host without permission", {
+ operation: "guac_access_denied",
+ userId,
+ hostId,
+ });
+ return res.status(403).json({ error: "Access denied to this host" });
+ }
+ }
+
+ const connectionType = (host.connectionType as string) || "ssh";
+ if (!["rdp", "vnc", "telnet"].includes(connectionType)) {
+ return res.status(400).json({
+ error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`,
+ });
+ }
+
+ let guacConfig: Record = {};
+ if (host.guacamoleConfig) {
+ try {
+ guacConfig =
+ typeof host.guacamoleConfig === "string"
+ ? JSON.parse(host.guacamoleConfig as string)
+ : (host.guacamoleConfig as Record);
+ } catch (error) {
+ guacLogger.warn("Failed to parse guacamole config", {
+ operation: "guac_config_parse_error",
+ hostId,
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ }
+
+ let token: string;
+ const hostname = host.ip as string;
+ const port = host.port as number;
+ const username = (host.username as string) || "";
+ const password = (host.password as string) || "";
+ const domain = (host.domain as string) || "";
+
+ switch (connectionType) {
+ case "rdp":
+ token = tokenService.createRdpToken(hostname, username, password, {
+ port: port || 3389,
+ domain,
+ security: (host.security as string) || undefined,
+ "ignore-cert": (host.ignoreCert as boolean) || false,
+ ...guacConfig,
+ });
+ break;
+ case "vnc":
+ token = tokenService.createVncToken(hostname, password, {
+ port: port || 5900,
+ ...guacConfig,
+ });
+ break;
+ case "telnet":
+ token = tokenService.createTelnetToken(hostname, username, password, {
+ port: port || 23,
+ ...guacConfig,
+ });
+ break;
+ default:
+ return res.status(400).json({ error: "Invalid connection type" });
+ }
+
+ res.json({ token });
+ } catch (error) {
+ guacLogger.error("Failed to generate guacamole token for host", error, {
+ operation: "guac_host_token_error",
+ });
+ res.status(500).json({ error: "Failed to generate connection token" });
+ }
+ },
+);
+
+/**
+ * GET /guacamole/status
+ * Check if guacd is reachable
+ */
+router.get("/status", async (req, res) => {
+ try {
+ let guacdHost = process.env.GUACD_HOST || "localhost";
+ let guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10);
+ try {
+ const db = getDb();
+ const urlRow = db.$client
+ .prepare("SELECT value FROM settings WHERE key = 'guac_url'")
+ .get() as { value: string } | undefined;
+ if (urlRow?.value) {
+ const parts = urlRow.value.split(":");
+ guacdHost = parts[0] || guacdHost;
+ guacdPort = parseInt(parts[1] || String(guacdPort), 10);
+ }
+ } catch {
+ // Fall back to env vars
+ }
+
+ const net = await import("net");
+
+ const checkConnection = (): Promise => {
+ return new Promise((resolve) => {
+ const socket = new net.Socket();
+ socket.setTimeout(3000);
+
+ socket.on("connect", () => {
+ socket.destroy();
+ resolve(true);
+ });
+
+ socket.on("timeout", () => {
+ socket.destroy();
+ resolve(false);
+ });
+
+ socket.on("error", () => {
+ socket.destroy();
+ resolve(false);
+ });
+
+ socket.connect(guacdPort, guacdHost);
+ });
+ };
+
+ const isConnected = await checkConnection();
+
+ res.json({
+ guacd: {
+ host: guacdHost,
+ port: guacdPort,
+ status: isConnected ? "connected" : "disconnected",
+ },
+ websocket: {
+ port: 30008,
+ status: "running",
+ },
+ });
+ } catch (error) {
+ guacLogger.error("Failed to check guacamole status", error, {
+ operation: "guac_status_error",
+ });
+ res.status(500).json({ error: "Failed to check status" });
+ }
+});
+
+export default router;
diff --git a/src/backend/guacamole/token-service.ts b/src/backend/guacamole/token-service.ts
new file mode 100644
index 00000000..647325c4
--- /dev/null
+++ b/src/backend/guacamole/token-service.ts
@@ -0,0 +1,181 @@
+import crypto from "crypto";
+import { guacLogger } from "../utils/logger.js";
+
+export interface GuacamoleConnectionSettings {
+ type: "rdp" | "vnc" | "telnet";
+ settings: {
+ hostname: string;
+ port?: number;
+ username?: string;
+ password?: string;
+ domain?: string;
+ width?: number;
+ height?: number;
+ dpi?: number;
+ security?: string;
+ "ignore-cert"?: boolean;
+ "enable-wallpaper"?: boolean;
+ "enable-drive"?: boolean;
+ "drive-path"?: string;
+ "create-drive-path"?: boolean;
+ "swap-red-blue"?: boolean;
+ cursor?: string;
+ "terminal-type"?: string;
+ [key: string]: unknown;
+ };
+}
+
+export interface GuacamoleToken {
+ connection: GuacamoleConnectionSettings;
+}
+
+const CIPHER = "aes-256-cbc";
+const KEY_LENGTH = 32;
+
+export class GuacamoleTokenService {
+ private static instance: GuacamoleTokenService;
+ private encryptionKey: Buffer;
+
+ private constructor() {
+ this.encryptionKey = this.initializeKey();
+ }
+
+ static getInstance(): GuacamoleTokenService {
+ if (!GuacamoleTokenService.instance) {
+ GuacamoleTokenService.instance = new GuacamoleTokenService();
+ }
+ return GuacamoleTokenService.instance;
+ }
+
+ private initializeKey(): Buffer {
+ const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY;
+ if (existingKey) {
+ if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) {
+ return Buffer.from(existingKey, "hex");
+ }
+ if (existingKey.length === KEY_LENGTH) {
+ return Buffer.from(existingKey, "utf8");
+ }
+ }
+
+ const jwtSecret = process.env.JWT_SECRET;
+ if (jwtSecret) {
+ return crypto
+ .createHash("sha256")
+ .update(jwtSecret + "_guacamole")
+ .digest();
+ }
+
+ guacLogger.warn(
+ "No persistent encryption key found, generating random key",
+ {
+ operation: "guac_key_generation",
+ },
+ );
+ return crypto.randomBytes(KEY_LENGTH);
+ }
+
+ getEncryptionKey(): Buffer {
+ return this.encryptionKey;
+ }
+
+ encryptToken(tokenObject: GuacamoleToken): string {
+ const iv = crypto.randomBytes(16);
+ const cipher = crypto.createCipheriv(CIPHER, this.encryptionKey, iv);
+
+ let encrypted = cipher.update(
+ JSON.stringify(tokenObject),
+ "utf8",
+ "base64",
+ );
+ encrypted += cipher.final("base64");
+
+ const data = {
+ iv: iv.toString("base64"),
+ value: encrypted,
+ };
+
+ return Buffer.from(JSON.stringify(data)).toString("base64");
+ }
+
+ decryptToken(token: string): GuacamoleToken | null {
+ try {
+ const data = JSON.parse(Buffer.from(token, "base64").toString("utf8"));
+ const iv = Buffer.from(data.iv, "base64");
+ const decipher = crypto.createDecipheriv(CIPHER, this.encryptionKey, iv);
+
+ let decrypted = decipher.update(data.value, "base64", "utf8");
+ decrypted += decipher.final("utf8");
+
+ return JSON.parse(decrypted) as GuacamoleToken;
+ } catch (error) {
+ guacLogger.error("Failed to decrypt guacamole token", error, {
+ operation: "guac_token_decrypt_error",
+ });
+ return null;
+ }
+ }
+
+ createRdpToken(
+ hostname: string,
+ username: string,
+ password: string,
+ options: Partial = {},
+ ): string {
+ const token: GuacamoleToken = {
+ connection: {
+ type: "rdp",
+ settings: {
+ hostname,
+ username,
+ password,
+ port: 3389,
+ security: "nla",
+ "ignore-cert": true,
+ ...options,
+ },
+ },
+ };
+ return this.encryptToken(token);
+ }
+
+ createVncToken(
+ hostname: string,
+ password?: string,
+ options: Partial = {},
+ ): string {
+ const token: GuacamoleToken = {
+ connection: {
+ type: "vnc",
+ settings: {
+ hostname,
+ password,
+ port: 5900,
+ ...options,
+ },
+ },
+ };
+ return this.encryptToken(token);
+ }
+
+ createTelnetToken(
+ hostname: string,
+ username?: string,
+ password?: string,
+ options: Partial = {},
+ ): string {
+ const token: GuacamoleToken = {
+ connection: {
+ type: "telnet",
+ settings: {
+ hostname,
+ username,
+ password,
+ port: 23,
+ ...options,
+ },
+ },
+ };
+ return this.encryptToken(token);
+ }
+}
diff --git a/src/backend/ssh/docker-console.ts b/src/backend/ssh/docker-console.ts
index eee441b4..c7cc82b1 100644
--- a/src/backend/ssh/docker-console.ts
+++ b/src/backend/ssh/docker-console.ts
@@ -2,7 +2,7 @@ import { Client as SSHClient } from "ssh2";
import { WebSocketServer, WebSocket } from "ws";
import { parse as parseUrl } from "url";
import { AuthManager } from "../utils/auth-manager.js";
-import { sshData, sshCredentials } from "../database/db/schema.js";
+import { hosts, sshCredentials } from "../database/db/schema.js";
import { and, eq } from "drizzle-orm";
import { getDb } from "../database/db/index.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -24,7 +24,7 @@ const activeSessions = new Map();
const wss = new WebSocketServer({
host: "0.0.0.0",
- port: 30008,
+ port: 30009,
verifyClient: async (info) => {
try {
const url = parseUrl(info.req.url || "", true);
@@ -107,8 +107,8 @@ async function createJumpHostChain(
const jumpHostData = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
- .where(and(eq(sshData.id, jumpHostId), eq(sshData.userId, userId))),
+ .from(hosts)
+ .where(and(eq(hosts.id, jumpHostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts
index b2d8cdf5..9c3e08f3 100644
--- a/src/backend/ssh/docker.ts
+++ b/src/backend/ssh/docker.ts
@@ -4,7 +4,7 @@ import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js";
-import { sshData, sshCredentials } from "../database/db/schema.js";
+import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { logger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -136,20 +136,20 @@ async function resolveJumpHost(
userId: string,
): Promise {
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
- if (hosts.length === 0) {
+ if (hostResults.length === 0) {
return null;
}
- const host = hosts[0];
+ const host = hostResults[0];
if (host.credentialId) {
const credentials = await SimpleDBOps.select(
@@ -570,25 +570,24 @@ app.post("/docker/ssh/connect", async (req, res) => {
);
try {
- const hosts = await SimpleDBOps.select(
- getDb().select().from(sshData).where(eq(sshData.id, hostId)),
+ const hostResults = await SimpleDBOps.select(
+ getDb().select().from(hosts).where(eq(hosts.id, hostId)),
"ssh_data",
userId,
);
- if (hosts.length === 0) {
+ if (hostResults.length === 0) {
connectionLogs.push(
createConnectionLog("error", "docker_connecting", "Host not found"),
);
return res.status(404).json({ error: "Host not found", connectionLogs });
}
- const host = hosts[0] as unknown as SSHHost;
+ const host = hostResults[0] as unknown as SSHHost;
if (host.userId !== userId) {
- const { PermissionManager } = await import(
- "../utils/permission-manager.js"
- );
+ const { PermissionManager } =
+ await import("../utils/permission-manager.js");
const permissionManager = PermissionManager.getInstance();
const accessInfo = await permissionManager.canAccessHost(
userId,
@@ -693,9 +692,8 @@ app.post("/docker/ssh/connect", async (req, res) => {
if (userId !== ownerId) {
try {
- const { SharedCredentialManager } = await import(
- "../utils/shared-credential-manager.js"
- );
+ const { SharedCredentialManager } =
+ await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id,
@@ -1678,14 +1676,14 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
if (session.hostId && session.userId) {
(async () => {
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
+ .from(hosts)
.where(
and(
- eq(sshData.id, session.hostId!),
- eq(sshData.userId, session.userId!),
+ eq(hosts.id, session.hostId!),
+ eq(hosts.userId, session.userId!),
),
),
"ssh_data",
@@ -1693,8 +1691,8 @@ app.post("/docker/ssh/connect-totp", async (req, res) => {
);
const hostName =
- hosts.length > 0 && hosts[0].name
- ? hosts[0].name
+ hostResults.length > 0 && hostResults[0].name
+ ? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
await axios.post(
@@ -1863,14 +1861,14 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
if (session.hostId && session.userId) {
(async () => {
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
+ .from(hosts)
.where(
and(
- eq(sshData.id, session.hostId!),
- eq(sshData.userId, session.userId!),
+ eq(hosts.id, session.hostId!),
+ eq(hosts.userId, session.userId!),
),
),
"ssh_data",
@@ -1878,8 +1876,8 @@ app.post("/docker/ssh/connect-warpgate", async (req, res) => {
);
const hostName =
- hosts.length > 0 && hosts[0].name
- ? hosts[0].name
+ hostResults.length > 0 && hostResults[0].name
+ ? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
await axios.post(
diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts
index c108c60d..df4f779b 100644
--- a/src/backend/ssh/file-manager.ts
+++ b/src/backend/ssh/file-manager.ts
@@ -4,7 +4,7 @@ import cookieParser from "cookie-parser";
import axios from "axios";
import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js";
-import { sshCredentials, sshData } from "../database/db/schema.js";
+import { sshCredentials, hosts } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { fileLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -179,20 +179,20 @@ async function resolveJumpHost(
userId: string,
): Promise {
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
- if (hosts.length === 0) {
+ if (hostResults.length === 0) {
return null;
}
- const host = hosts[0];
+ const host = hostResults[0];
if (host.credentialId) {
const credentials = await SimpleDBOps.select(
@@ -803,64 +803,137 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
let resolvedCredentials = { password, sshKey, keyPassword, authType };
if (credentialId && hostId && userId) {
- try {
- const credentials = await SimpleDBOps.select(
- getDb()
- .select()
- .from(sshCredentials)
- .where(
- and(
- eq(sshCredentials.id, credentialId),
- eq(sshCredentials.userId, userId),
- ),
- ),
- "ssh_credentials",
- userId,
- );
+ const hostRow = await getDb()
+ .select({ userId: hosts.userId })
+ .from(hosts)
+ .where(eq(hosts.id, hostId))
+ .limit(1);
+ const ownerId = hostRow[0]?.userId ?? null;
- if (credentials.length > 0) {
- const credential = credentials[0];
- resolvedCredentials = {
- password: credential.password,
- sshKey: credential.privateKey,
- keyPassword: credential.keyPassword,
- authType: credential.authType,
- };
+ if (ownerId && userId !== ownerId) {
+ try {
+ const { SharedCredentialManager } =
+ await import("../utils/shared-credential-manager.js");
+ const sharedCredManager = SharedCredentialManager.getInstance();
+ const sharedCred = await sharedCredManager.getSharedCredentialForUser(
+ hostId,
+ userId,
+ );
+
+ if (sharedCred) {
+ resolvedCredentials = {
+ password: sharedCred.password,
+ sshKey: sharedCred.key,
+ keyPassword: sharedCred.keyPassword,
+ authType: sharedCred.authType,
+ };
+ connectionLogs.push(
+ createConnectionLog(
+ "info",
+ "sftp_auth",
+ "Credentials resolved from shared credential store",
+ ),
+ );
+ } else {
+ fileLogger.warn(`No shared credentials found for host ${hostId}`, {
+ operation: "ssh_credentials",
+ hostId,
+ userId,
+ });
+ connectionLogs.push(
+ createConnectionLog(
+ "warning",
+ "sftp_auth",
+ "No shared credentials found, using provided credentials",
+ ),
+ );
+ }
+ } catch (error) {
+ fileLogger.warn(
+ `Failed to resolve shared credential for host ${hostId}`,
+ {
+ operation: "ssh_credentials",
+ hostId,
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ );
connectionLogs.push(
createConnectionLog(
- "info",
+ "warning",
"sftp_auth",
- "Credentials resolved from credential store",
+ `Failed to resolve shared credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
),
);
- } else {
- fileLogger.warn(`No credentials found for host ${hostId}`, {
+ }
+ } else if (ownerId) {
+ try {
+ const credentials = await SimpleDBOps.select(
+ getDb()
+ .select()
+ .from(sshCredentials)
+ .where(
+ and(
+ eq(sshCredentials.id, credentialId),
+ eq(sshCredentials.userId, ownerId),
+ ),
+ ),
+ "ssh_credentials",
+ ownerId,
+ );
+
+ if (credentials.length > 0) {
+ const credential = credentials[0];
+ resolvedCredentials = {
+ password: credential.password,
+ sshKey: credential.privateKey,
+ keyPassword: credential.keyPassword,
+ authType: credential.authType,
+ };
+ connectionLogs.push(
+ createConnectionLog(
+ "info",
+ "sftp_auth",
+ "Credentials resolved from credential store",
+ ),
+ );
+ } else {
+ fileLogger.warn(`No credentials found for host ${hostId}`, {
+ operation: "ssh_credentials",
+ hostId,
+ credentialId,
+ userId: ownerId,
+ });
+ connectionLogs.push(
+ createConnectionLog(
+ "warning",
+ "sftp_auth",
+ "No stored credentials found, using provided credentials",
+ ),
+ );
+ }
+ } catch (error) {
+ fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
operation: "ssh_credentials",
hostId,
credentialId,
- userId,
+ error: error instanceof Error ? error.message : "Unknown error",
});
connectionLogs.push(
createConnectionLog(
"warning",
"sftp_auth",
- "No stored credentials found, using provided credentials",
+ `Failed to resolve credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
),
);
}
- } catch (error) {
- fileLogger.warn(`Failed to resolve credentials for host ${hostId}`, {
- operation: "ssh_credentials",
- hostId,
- credentialId,
- error: error instanceof Error ? error.message : "Unknown error",
- });
- connectionLogs.push(
- createConnectionLog(
- "warning",
- "sftp_auth",
- `Failed to resolve credentials: ${error instanceof Error ? error.message : "Unknown error"}`,
- ),
+ } else {
+ fileLogger.warn(
+ "Missing userId for credential resolution in file manager",
+ {
+ operation: "ssh_credentials",
+ hostId,
+ credentialId,
+ },
);
}
} else if (credentialId && hostId) {
@@ -1201,18 +1274,18 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
if (hostId && userId) {
(async () => {
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
const hostName =
- hosts.length > 0 && hosts[0].name
- ? hosts[0].name
+ hostResults.length > 0 && hostResults[0].name
+ ? hostResults[0].name
: `${username}@${ip}:${port}`;
const authManager = AuthManager.getInstance();
@@ -1844,14 +1917,14 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
if (session.hostId && session.userId) {
(async () => {
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
+ .from(hosts)
.where(
and(
- eq(sshData.id, session.hostId!),
- eq(sshData.userId, session.userId!),
+ eq(hosts.id, session.hostId!),
+ eq(hosts.userId, session.userId!),
),
),
"ssh_data",
@@ -1859,8 +1932,8 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
);
const hostName =
- hosts.length > 0 && hosts[0].name
- ? hosts[0].name
+ hostResults.length > 0 && hostResults[0].name
+ ? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
const authManager = AuthManager.getInstance();
@@ -2045,14 +2118,14 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
if (session.hostId && session.userId) {
(async () => {
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
+ .from(hosts)
.where(
and(
- eq(sshData.id, session.hostId!),
- eq(sshData.userId, session.userId!),
+ eq(hosts.id, session.hostId!),
+ eq(hosts.userId, session.userId!),
),
),
"ssh_data",
@@ -2060,8 +2133,8 @@ app.post("/ssh/file_manager/ssh/connect-warpgate", async (req, res) => {
);
const hostName =
- hosts.length > 0 && hosts[0].name
- ? hosts[0].name
+ hostResults.length > 0 && hostResults[0].name
+ ? hostResults[0].name
: `${session.username}@${session.ip}:${session.port}`;
await axios.post(
diff --git a/src/backend/ssh/host-key-verifier.ts b/src/backend/ssh/host-key-verifier.ts
index 81ed056f..8edf81b3 100644
--- a/src/backend/ssh/host-key-verifier.ts
+++ b/src/backend/ssh/host-key-verifier.ts
@@ -1,6 +1,6 @@
import type { WebSocket } from "ws";
import { db } from "../database/db/index.js";
-import { sshData } from "../database/db/schema.js";
+import { hosts } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
@@ -21,17 +21,6 @@ interface VerificationResponse {
}
export class SSHHostKeyVerifier {
- /**
- * Creates a hostVerifier callback for ssh2 Client.connect()
- *
- * @param hostId - Database ID of the host (null for quick connect)
- * @param ip - IP address or hostname
- * @param port - SSH port
- * @param ws - WebSocket for user prompts (null for non-interactive connections)
- * @param userId - User ID for logging
- * @param isJumpHost - If true, auto-accepts without prompting
- * @returns async hostVerifier callback
- */
static async createHostVerifier(
hostId: number | null,
ip: string,
@@ -63,8 +52,8 @@ export class SSHHostKeyVerifier {
return;
}
- const host = await db.query.sshData.findFirst({
- where: eq(sshData.id, hostId),
+ const host = await db.query.hosts.findFirst({
+ where: eq(hosts.id, hostId),
});
if (!host) {
@@ -153,11 +142,11 @@ export class SSHHostKeyVerifier {
if (host.hostKeyFingerprint === fingerprint) {
await db
- .update(sshData)
+ .update(hosts)
.set({
hostKeyLastVerified: new Date().toISOString(),
})
- .where(eq(sshData.id, hostId));
+ .where(eq(hosts.id, hostId));
sshLogger.info("Host key verified successfully", {
operation: "host_key_verified",
@@ -287,7 +276,7 @@ export class SSHHostKeyVerifier {
algorithm: string,
): Promise {
await db
- .update(sshData)
+ .update(hosts)
.set({
hostKeyFingerprint: fingerprint,
hostKeyType: keyType,
@@ -295,7 +284,7 @@ export class SSHHostKeyVerifier {
hostKeyFirstSeen: new Date().toISOString(),
hostKeyLastVerified: new Date().toISOString(),
})
- .where(eq(sshData.id, hostId));
+ .where(eq(hosts.id, hostId));
}
private static async updateHostKey(
@@ -306,7 +295,7 @@ export class SSHHostKeyVerifier {
currentChangeCount: number,
): Promise {
await db
- .update(sshData)
+ .update(hosts)
.set({
hostKeyFingerprint: fingerprint,
hostKeyType: keyType,
@@ -314,7 +303,7 @@ export class SSHHostKeyVerifier {
hostKeyLastVerified: new Date().toISOString(),
hostKeyChangedCount: currentChangeCount + 1,
})
- .where(eq(sshData.id, hostId));
+ .where(eq(hosts.id, hostId));
}
private static async promptUserForNewKey(
diff --git a/src/backend/ssh/opkssh-auth.ts b/src/backend/ssh/opkssh-auth.ts
index ed906df9..d4b919fb 100644
--- a/src/backend/ssh/opkssh-auth.ts
+++ b/src/backend/ssh/opkssh-auth.ts
@@ -123,7 +123,7 @@ async function checkOPKConfigExists(): Promise<{
return {
exists: false,
configPath,
- error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/ssh/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`,
+ error: `OPKSSH configuration is missing 'redirect_uris' field. This field must contain the Termix callback URL that you registered with your OAuth provider (e.g., http://localhost:8080/host/opkssh-callback for Docker). The static callback route will internally redirect to the dynamic route for proper URL rewriting.`,
};
}
@@ -179,7 +179,7 @@ export async function startOPKSSHAuth(
}
const requestId = randomUUID();
- const remoteRedirectUri = `${requestOrigin}/ssh/opkssh-callback`;
+ const remoteRedirectUri = `${requestOrigin}/host/opkssh-callback`;
const session: Partial = {
requestId,
@@ -352,7 +352,7 @@ function handleOPKSSHOutput(requestId: string, output: string): void {
/\/ssh\/opkssh-callback$/,
"",
);
- const proxiedChooserUrl = `${baseUrl}/ssh/opkssh-chooser/${requestId}`;
+ const proxiedChooserUrl = `${baseUrl}/host/opkssh-chooser/${requestId}`;
session.status = "waiting_for_auth";
session.ws.send(
diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts
index 3cec063e..7562e1b6 100644
--- a/src/backend/ssh/server-stats.ts
+++ b/src/backend/ssh/server-stats.ts
@@ -4,7 +4,7 @@ import cors from "cors";
import cookieParser from "cookie-parser";
import { Client, type ConnectConfig } from "ssh2";
import { getDb } from "../database/db/index.js";
-import { sshData, sshCredentials } from "../database/db/schema.js";
+import { hosts, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { statsLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -29,6 +29,15 @@ import {
import { SSHHostKeyVerifier } from "./host-key-verifier.js";
import { connectionPool, withConnection } from "./ssh-connection-pool.js";
+function supportsMetrics(host: SSHHostWithCredentials): boolean {
+ const connectionType = host.connectionType || "ssh";
+ return connectionType === "ssh";
+}
+
+function isTcpPingEnabled(statsConfig: StatsConfig): boolean {
+ return statsConfig.statusCheckEnabled && !statsConfig.disableTcpPing;
+}
+
function createConnectionLog(
type: "info" | "success" | "warning" | "error",
stage: ConnectionStage,
@@ -62,20 +71,20 @@ async function resolveJumpHost(
userId: string,
): Promise {
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
- if (hosts.length === 0) {
+ if (hostResults.length === 0) {
return null;
}
- const host = hosts[0];
+ const host = hostResults[0];
if (host.credentialId) {
const credentials = await SimpleDBOps.select(
@@ -644,6 +653,7 @@ interface SSHHostWithCredentials {
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: ProxyNode[];
+ connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
}
type StatusEntry = {
@@ -659,6 +669,7 @@ interface StatsConfig {
metricsEnabled: boolean;
metricsInterval: number;
useGlobalMetricsInterval?: boolean;
+ disableTcpPing?: boolean;
}
const DEFAULT_STATS_CONFIG: StatsConfig = {
@@ -783,9 +794,13 @@ class PollingManager {
const statusOnly = options?.statusOnly ?? false;
const viewerUserId = options?.viewerUserId;
+ const canCollectMetrics = supportsMetrics(host);
+
const enabledCollectors: string[] = [];
- if (statsConfig.statusCheckEnabled) enabledCollectors.push("status");
- if (!statusOnly && statsConfig.metricsEnabled) {
+ if (isTcpPingEnabled(statsConfig)) {
+ enabledCollectors.push("status");
+ }
+ if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
enabledCollectors.push(
"cpu",
"memory",
@@ -810,7 +825,7 @@ class PollingManager {
}
}
- if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) {
+ if (!isTcpPingEnabled(statsConfig) && !statsConfig.metricsEnabled) {
this.pollingConfigs.delete(host.id);
this.statusStore.delete(host.id);
this.metricsStore.delete(host.id);
@@ -823,14 +838,14 @@ class PollingManager {
viewerUserId,
};
- if (statsConfig.statusCheckEnabled) {
+ if (isTcpPingEnabled(statsConfig)) {
const intervalMs = statsConfig.statusCheckInterval * 1000;
this.pollHostStatus(host, viewerUserId);
config.statusTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
- if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) {
+ if (latestConfig && isTcpPingEnabled(latestConfig.statsConfig)) {
this.pollHostStatus(latestConfig.host, latestConfig.viewerUserId);
}
}, intervalMs);
@@ -838,14 +853,18 @@ class PollingManager {
this.statusStore.delete(host.id);
}
- if (!statusOnly && statsConfig.metricsEnabled) {
+ if (!statusOnly && statsConfig.metricsEnabled && canCollectMetrics) {
const intervalMs = statsConfig.metricsInterval * 1000;
await this.pollHostMetrics(host, viewerUserId);
config.metricsTimer = setInterval(() => {
const latestConfig = this.pollingConfigs.get(host.id);
- if (latestConfig && latestConfig.statsConfig.metricsEnabled) {
+ if (
+ latestConfig &&
+ latestConfig.statsConfig.metricsEnabled &&
+ supportsMetrics(latestConfig.host)
+ ) {
this.pollHostMetrics(latestConfig.host, latestConfig.viewerUserId);
}
}, intervalMs);
@@ -896,6 +915,15 @@ class PollingManager {
return;
}
+ if (!supportsMetrics(refreshedHost)) {
+ statsLogger.debug("Skipping metrics collection for non-SSH host", {
+ operation: "poll_host_metrics_skipped",
+ hostId: refreshedHost.id,
+ connectionType: refreshedHost.connectionType || "ssh",
+ });
+ return;
+ }
+
const config = this.pollingConfigs.get(refreshedHost.id);
if (!config || !config.statsConfig.metricsEnabled) {
return;
@@ -1170,14 +1198,14 @@ async function fetchAllHosts(
userId: string,
): Promise {
try {
- const hosts = await SimpleDBOps.select(
- getDb().select().from(sshData).where(eq(sshData.userId, userId)),
+ const hostResults = await SimpleDBOps.select(
+ getDb().select().from(hosts).where(eq(hosts.userId, userId)),
"ssh_data",
userId,
);
const hostsWithCredentials: SSHHostWithCredentials[] = [];
- for (const host of hosts) {
+ for (const host of hostResults) {
try {
const hostWithCreds = await resolveHostCredentials(host, userId);
if (hostWithCreds) {
@@ -1221,17 +1249,17 @@ async function fetchHostById(
return undefined;
}
- const hosts = await SimpleDBOps.select(
- getDb().select().from(sshData).where(eq(sshData.id, id)),
+ const hostResults = await SimpleDBOps.select(
+ getDb().select().from(hosts).where(eq(hosts.id, id)),
"ssh_data",
userId,
);
- if (hosts.length === 0) {
+ if (hostResults.length === 0) {
return undefined;
}
- const host = hosts[0];
+ const host = hostResults[0];
return await resolveHostCredentials(host, userId);
} catch (err) {
statsLogger.error(`Failed to fetch host ${id}`, err);
@@ -1757,6 +1785,10 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
os: string | null;
};
}> {
+ if (!supportsMetrics(host)) {
+ throw new Error("Metrics collection only supported for SSH hosts");
+ }
+
if (authFailureTracker.shouldSkip(host.id)) {
const reason = authFailureTracker.getSkipReason(host.id);
throw new Error(reason || "Authentication failed");
@@ -1935,7 +1967,8 @@ function tcpPing(
socket.once("data", (data) => {
clearTimeout(dataTimeout);
- if (data.toString().startsWith("SSH-")) {
+ const dataStr = data.toString("utf8");
+ if (dataStr.startsWith("SSH-")) {
try {
socket.end("SSH-2.0-TermixHealthCheck\r\n");
} catch {
diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts
index 431a27a5..3466cd7c 100644
--- a/src/backend/ssh/terminal.ts
+++ b/src/backend/ssh/terminal.ts
@@ -3,7 +3,7 @@ import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import { parse as parseUrl } from "url";
import axios from "axios";
import { getDb } from "../database/db/index.js";
-import { sshCredentials, sshData } from "../database/db/schema.js";
+import { sshCredentials, hosts } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { sshLogger, authLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
@@ -97,20 +97,20 @@ async function resolveJumpHost(
hostId,
});
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))),
"ssh_data",
userId,
);
- if (hosts.length === 0) {
+ if (hostResults.length === 0) {
return null;
}
- const host = hosts[0];
+ const host = hostResults[0];
if (host.credentialId) {
const credentials = await SimpleDBOps.select(
@@ -814,8 +814,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
const db = getDb();
const hostRow = await db
.select()
- .from(sshData)
- .where(eq(sshData.id, opksshData.hostId))
+ .from(hosts)
+ .where(eq(hosts.id, opksshData.hostId))
.limit(1);
if (!hostRow || hostRow.length === 0) {
sshLogger.error(
@@ -1057,55 +1057,96 @@ wss.on("connection", async (ws: WebSocket, req) => {
authType,
};
const authMethodNotAvailable = false;
- if (credentialId && id && hostConfig.userId) {
- try {
- const credentials = await SimpleDBOps.select(
- getDb()
- .select()
- .from(sshCredentials)
- .where(
- and(
- eq(sshCredentials.id, credentialId),
- eq(sshCredentials.userId, hostConfig.userId),
- ),
- ),
- "ssh_credentials",
- hostConfig.userId,
- );
+ if (credentialId && id) {
+ const hostRow = await getDb()
+ .select({ userId: hosts.userId })
+ .from(hosts)
+ .where(eq(hosts.id, id))
+ .limit(1);
+ const ownerId = hostRow[0]?.userId ?? null;
- if (credentials.length > 0) {
- const credential = credentials[0];
- resolvedCredentials = {
- username: (credential.username as string | undefined) || username,
- password: credential.password as string | undefined,
- key: credential.privateKey as string | undefined,
- keyPassword: credential.keyPassword as string | undefined,
- keyType: credential.keyType as string | undefined,
- authType: credential.authType as string | undefined,
- };
- } else {
- sshLogger.warn(`No credentials found for host ${id}`, {
+ if (ownerId && userId !== ownerId) {
+ try {
+ const { SharedCredentialManager } =
+ await import("../utils/shared-credential-manager.js");
+ const sharedCredManager = SharedCredentialManager.getInstance();
+ const sharedCred = await sharedCredManager.getSharedCredentialForUser(
+ id,
+ userId,
+ );
+
+ if (sharedCred) {
+ resolvedCredentials = {
+ username: sharedCred.username || username,
+ password: sharedCred.password,
+ key: sharedCred.key,
+ keyPassword: sharedCred.keyPassword,
+ keyType: sharedCred.keyType,
+ authType: sharedCred.authType,
+ };
+ } else {
+ sshLogger.warn(`No shared credentials found for host ${id}`, {
+ operation: "ssh_credentials",
+ userId,
+ hostId: id,
+ });
+ }
+ } catch (error) {
+ sshLogger.warn(`Failed to resolve shared credential for host ${id}`, {
+ operation: "ssh_credentials",
+ hostId: id,
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ } else if (ownerId) {
+ try {
+ const credentials = await SimpleDBOps.select(
+ getDb()
+ .select()
+ .from(sshCredentials)
+ .where(
+ and(
+ eq(sshCredentials.id, credentialId),
+ eq(sshCredentials.userId, ownerId),
+ ),
+ ),
+ "ssh_credentials",
+ ownerId,
+ );
+
+ if (credentials.length > 0) {
+ const credential = credentials[0];
+ resolvedCredentials = {
+ username: (credential.username as string | undefined) || username,
+ password: credential.password as string | undefined,
+ key: credential.privateKey as string | undefined,
+ keyPassword: credential.keyPassword as string | undefined,
+ keyType: credential.keyType as string | undefined,
+ authType: credential.authType as string | undefined,
+ };
+ } else {
+ sshLogger.warn(`No credentials found for host ${id}`, {
+ operation: "ssh_credentials",
+ hostId: id,
+ credentialId,
+ userId: ownerId,
+ });
+ }
+ } catch (error) {
+ sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
operation: "ssh_credentials",
hostId: id,
credentialId,
- userId: hostConfig.userId,
+ error: error instanceof Error ? error.message : "Unknown error",
});
}
- } catch (error) {
- sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
+ } else {
+ sshLogger.warn("Missing userId for credential resolution in terminal", {
operation: "ssh_credentials",
hostId: id,
credentialId,
- error: error instanceof Error ? error.message : "Unknown error",
});
}
- } else if (credentialId && id) {
- sshLogger.warn("Missing userId for credential resolution in terminal", {
- operation: "ssh_credentials",
- hostId: id,
- credentialId,
- hasUserId: !!hostConfig.userId,
- });
}
sshConn.on("ready", () => {
@@ -1400,14 +1441,14 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (id && hostConfig.userId) {
(async () => {
try {
- const hosts = await SimpleDBOps.select(
+ const hostResults = await SimpleDBOps.select(
getDb()
.select()
- .from(sshData)
+ .from(hosts)
.where(
and(
- eq(sshData.id, id),
- eq(sshData.userId, hostConfig.userId!),
+ eq(hosts.id, id),
+ eq(hosts.userId, hostConfig.userId!),
),
),
"ssh_data",
@@ -1415,8 +1456,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
const hostName =
- hosts.length > 0 && hosts[0].name
- ? hosts[0].name
+ hostResults.length > 0 && hostResults[0].name
+ ? hostResults[0].name
: `${username}@${ip}:${port}`;
await axios.post(
diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts
index 8cb60afd..b17a2eae 100644
--- a/src/backend/ssh/tunnel.ts
+++ b/src/backend/ssh/tunnel.ts
@@ -608,9 +608,8 @@ async function connectSSHTunnel(
tunnelConfig.requestingUserId &&
tunnelConfig.requestingUserId !== tunnelConfig.sourceUserId
) {
- const { SharedCredentialManager } = await import(
- "../utils/shared-credential-manager.js"
- );
+ const { SharedCredentialManager } =
+ await import("../utils/shared-credential-manager.js");
const sharedCredManager = SharedCredentialManager.getInstance();
if (tunnelConfig.sourceHostId) {
@@ -1750,7 +1749,7 @@ app.post(
const internalAuthToken = await systemCrypto.getInternalAuthToken();
const allHostsResponse = await axios.get(
- "http://localhost:30001/ssh/db/host/internal/all",
+ "http://localhost:30001/host/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
@@ -2028,7 +2027,7 @@ async function initializeAutoStartTunnels(): Promise {
const internalAuthToken = await systemCrypto.getInternalAuthToken();
const autostartResponse = await axios.get(
- "http://localhost:30001/ssh/db/host/internal",
+ "http://localhost:30001/host/db/host/internal",
{
headers: {
"Content-Type": "application/json",
@@ -2038,7 +2037,7 @@ async function initializeAutoStartTunnels(): Promise {
);
const allHostsResponse = await axios.get(
- "http://localhost:30001/ssh/db/host/internal/all",
+ "http://localhost:30001/host/db/host/internal/all",
{
headers: {
"Content-Type": "application/json",
@@ -2050,7 +2049,6 @@ async function initializeAutoStartTunnels(): Promise {
const autostartHosts: SSHHost[] = autostartResponse.data || [];
const allHosts: SSHHost[] = allHostsResponse.data || [];
const autoStartTunnels: TunnelConfig[] = [];
-
tunnelLogger.info(
`Found ${autostartHosts.length} autostart hosts and ${allHosts.length} total hosts for endpointHost resolution`,
);
diff --git a/src/backend/starter.ts b/src/backend/starter.ts
index dc102a3f..8d566eca 100644
--- a/src/backend/starter.ts
+++ b/src/backend/starter.ts
@@ -112,9 +112,8 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
await authManager.initialize();
DataCrypto.initialize();
- const { OPKSSHBinaryManager } = await import(
- "./utils/opkssh-binary-manager.js"
- );
+ const { OPKSSHBinaryManager } =
+ await import("./utils/opkssh-binary-manager.js");
try {
await OPKSSHBinaryManager.ensureBinary();
} catch (error) {
@@ -143,6 +142,33 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
await import("./ssh/docker-console.js");
await import("./dashboard.js");
+ // Initialize Guacamole server for RDP/VNC/Telnet support
+ const { getDb: getDbForGuac } = await import("./database/db/index.js");
+ const guacDb = getDbForGuac();
+ const guacEnabledRow = guacDb.$client
+ .prepare("SELECT value FROM settings WHERE key = 'guac_enabled'")
+ .get() as { value: string } | undefined;
+ const guacEnabled = guacEnabledRow
+ ? guacEnabledRow.value !== "false"
+ : true;
+
+ if (process.env.ENABLE_GUACAMOLE !== "false" && guacEnabled) {
+ try {
+ await import("./guacamole/guacamole-server.js");
+ systemLogger.info("Guacamole server initialized", {
+ operation: "guac_init",
+ });
+ } catch (error) {
+ systemLogger.warn(
+ "Failed to initialize Guacamole server (guacd may not be available)",
+ {
+ operation: "guac_init_skip",
+ error: error instanceof Error ? error.message : "Unknown error",
+ },
+ );
+ }
+ }
+
systemLogger.success("Termix backend started successfully", {
operation: "backend_init_complete",
port: process.env.PORT || 4090,
diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts
index cb5ff611..b15d45d2 100644
--- a/src/backend/utils/logger.ts
+++ b/src/backend/utils/logger.ts
@@ -254,5 +254,6 @@ export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
+export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
export const logger = systemLogger;
diff --git a/src/backend/utils/permission-manager.ts b/src/backend/utils/permission-manager.ts
index ff1cd4ec..bd214958 100644
--- a/src/backend/utils/permission-manager.ts
+++ b/src/backend/utils/permission-manager.ts
@@ -4,7 +4,7 @@ import {
hostAccess,
roles,
userRoles,
- sshData,
+ hosts,
users,
} from "../database/db/schema.js";
import { eq, and, or, isNull, gte, sql } from "drizzle-orm";
@@ -167,8 +167,8 @@ class PermissionManager {
try {
const host = await db
.select()
- .from(sshData)
- .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
+ .from(hosts)
+ .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.limit(1);
if (host.length > 0) {
@@ -210,9 +210,9 @@ class PermissionManager {
const access = sharedAccess[0];
const hostOwnerCheck = await db
- .select({ ownerId: sshData.userId })
- .from(sshData)
- .where(eq(sshData.id, hostId))
+ .select({ ownerId: hosts.userId })
+ .from(hosts)
+ .where(eq(hosts.id, hostId))
.limit(1);
if (hostOwnerCheck.length > 0 && hostOwnerCheck[0].ownerId === userId) {
diff --git a/src/backend/utils/shared-credential-manager.ts b/src/backend/utils/shared-credential-manager.ts
index d58f7eba..261ffa1a 100644
--- a/src/backend/utils/shared-credential-manager.ts
+++ b/src/backend/utils/shared-credential-manager.ts
@@ -4,7 +4,7 @@ import {
sshCredentials,
hostAccess,
userRoles,
- sshData,
+ hosts,
} from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { DataCrypto } from "./data-crypto.js";
@@ -190,15 +190,27 @@ class SharedCredentialManager {
const cred = sharedCred[0].shared_credentials;
if (cred.needsReEncryption) {
- databaseLogger.warn(
- "Shared credential needs re-encryption but cannot be accessed yet",
- {
- operation: "get_shared_credential_pending",
- hostId,
- userId,
- },
- );
- return null;
+ await this.reEncryptSharedCredential(cred.id, userId);
+
+ const refreshed = await db
+ .select()
+ .from(sharedCredentials)
+ .where(eq(sharedCredentials.id, cred.id))
+ .limit(1);
+
+ if (refreshed.length === 0 || refreshed[0].needsReEncryption) {
+ databaseLogger.warn(
+ "Shared credential needs re-encryption but cannot be accessed yet",
+ {
+ operation: "get_shared_credential_pending",
+ hostId,
+ userId,
+ },
+ );
+ return null;
+ }
+
+ return this.decryptSharedCredential(refreshed[0], userDEK);
}
return this.decryptSharedCredential(cred, userDEK);
@@ -587,7 +599,7 @@ class SharedCredentialManager {
const access = await db
.select()
.from(hostAccess)
- .innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
+ .innerJoin(hosts, eq(hostAccess.hostId, hosts.id))
.where(eq(hostAccess.id, cred.hostAccessId))
.limit(1);
diff --git a/src/backend/utils/user-agent-parser.ts b/src/backend/utils/user-agent-parser.ts
index 91e43c0d..efd0b21e 100644
--- a/src/backend/utils/user-agent-parser.ts
+++ b/src/backend/utils/user-agent-parser.ts
@@ -23,7 +23,18 @@ export function detectPlatform(req: Request): DeviceType {
return "mobile";
}
- if (userAgent.includes("Android")) {
+ const isDesktopOS =
+ userAgent.includes("Windows") ||
+ userAgent.includes("Macintosh") ||
+ userAgent.includes("Mac OS X") ||
+ userAgent.includes("X11") ||
+ userAgent.includes("Linux x86_64");
+
+ if (
+ (userAgent.includes("Android") && !isDesktopOS) ||
+ userAgent.includes("iPhone") ||
+ userAgent.includes("iPad")
+ ) {
return "mobile";
}
diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts
index 063cfbe1..b536bcf7 100644
--- a/src/backend/utils/user-crypto.ts
+++ b/src/backend/utils/user-crypto.ts
@@ -303,9 +303,8 @@ class UserCrypto {
await this.storeKEKSalt(userId, newKekSalt);
await this.storeEncryptedDEK(userId, newEncryptedDEK);
- const { saveMemoryDatabaseToFile } = await import(
- "../database/db/index.js"
- );
+ const { saveMemoryDatabaseToFile } =
+ await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
oldKEK.fill(0);
@@ -341,9 +340,8 @@ class UserCrypto {
await this.storeKEKSalt(userId, newKekSalt);
await this.storeEncryptedDEK(userId, newEncryptedDEK);
- const { saveMemoryDatabaseToFile } = await import(
- "../database/db/index.js"
- );
+ const { saveMemoryDatabaseToFile } =
+ await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
newKEK.fill(0);
@@ -418,9 +416,8 @@ class UserCrypto {
},
);
- const { saveMemoryDatabaseToFile } = await import(
- "../database/db/index.js"
- );
+ const { saveMemoryDatabaseToFile } =
+ await import("../database/db/index.js");
await saveMemoryDatabaseToFile();
} catch (error) {
databaseLogger.error("Failed to convert to OIDC encryption", error, {
diff --git a/src/backend/utils/user-data-export.ts b/src/backend/utils/user-data-export.ts
index 03c3fff3..c19a3266 100644
--- a/src/backend/utils/user-data-export.ts
+++ b/src/backend/utils/user-data-export.ts
@@ -1,7 +1,7 @@
import { getDb } from "../database/db/index.js";
import {
users,
- sshData,
+ hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
@@ -74,8 +74,8 @@ class UserDataExport {
const sshHosts = await getDb()
.select()
- .from(sshData)
- .where(eq(sshData.userId, userId));
+ .from(hosts)
+ .where(eq(hosts.userId, userId));
const processedSshHosts =
format === "plaintext" && userDataKey
? sshHosts.map((host) =>
diff --git a/src/backend/utils/user-data-import.ts b/src/backend/utils/user-data-import.ts
index 31783e35..bb1e14b9 100644
--- a/src/backend/utils/user-data-import.ts
+++ b/src/backend/utils/user-data-import.ts
@@ -1,7 +1,7 @@
import { getDb } from "../database/db/index.js";
import {
users,
- sshData,
+ hosts,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
@@ -179,13 +179,13 @@ class UserDataImport {
const existing = await getDb()
.select()
- .from(sshData)
+ .from(hosts)
.where(
and(
- eq(sshData.userId, targetUserId),
- eq(sshData.ip, host.ip as string),
- eq(sshData.port, host.port as number),
- eq(sshData.username, host.username as string),
+ eq(hosts.userId, targetUserId),
+ eq(hosts.ip, host.ip as string),
+ eq(hosts.port, host.port as number),
+ eq(hosts.username, host.username as string),
),
);
@@ -218,15 +218,13 @@ class UserDataImport {
if (existing.length > 0 && options.replaceExisting) {
await getDb()
- .update(sshData)
- .set(processedHostData as unknown as typeof sshData.$inferInsert)
- .where(eq(sshData.id, existing[0].id));
+ .update(hosts)
+ .set(processedHostData as unknown as typeof hosts.$inferInsert)
+ .where(eq(hosts.id, existing[0].id));
} else {
await getDb()
- .insert(sshData)
- .values(
- processedHostData as unknown as typeof sshData.$inferInsert,
- );
+ .insert(hosts)
+ .values(processedHostData as unknown as typeof hosts.$inferInsert);
}
imported++;
} catch (error) {
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 849574a3..aeaadfce 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -37,8 +37,7 @@ const buttonVariants = cva(
);
export interface ButtonProps
- extends React.ComponentProps<"button">,
- VariantProps {
+ extends React.ComponentProps<"button">, VariantProps {
asChild?: boolean;
}
diff --git a/src/locales/en.json b/src/locales/en.json
index 7dd7a1c4..0f5d6508 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -810,6 +810,15 @@
"globalSettingsSaved": "Global monitoring settings saved",
"failedToSaveGlobalSettings": "Failed to save global monitoring settings",
"failedToLoadGlobalSettings": "Failed to load global monitoring settings",
+ "guacamoleIntegration": "Remote Desktop Integration (Guacamole)",
+ "guacamoleIntegrationDesc": "Enable RDP, VNC, and Telnet connections via guacd. Requires a guacd instance to be running.",
+ "enableGuacamole": "Enable RDP/VNC/Telnet support",
+ "guacdUrl": "guacd URL (host:port)",
+ "guacdUrlPlaceholder": "guacd:4822",
+ "guacdUrlNote": "Changes to the guacd host/port require a server restart to take effect.",
+ "guacamoleSettingsSaved": "Guacamole settings saved",
+ "failedToSaveGuacamoleSettings": "Failed to save guacamole settings",
+ "failedToLoadGuacamoleSettings": "Failed to load guacamole settings",
"clampedToValidRange": "was adjusted to valid range",
"sessionManagement": "Session Management",
"loadingSessions": "Loading sessions...",
@@ -875,11 +884,21 @@
"importError": "Import error",
"failedToImportJson": "Failed to import JSON file",
"connectionDetails": "Connection Details",
+ "connectionType": "Connection Type",
+ "ssh": "SSH",
+ "rdp": "Remote Desktop (RDP)",
+ "vnc": "VNC",
+ "telnet": "Telnet",
+ "remoteDesktop": "Remote Desktop",
+ "guacamoleSettings": "Remote Desktop Settings",
"organization": "Organization",
"ipAddress": "IP Address or Hostname",
+ "ipRequired": "IP address is required",
+ "portRequired": "Port is required",
"port": "Port",
"name": "Name",
"username": "Username",
+ "usernameRequired": "Username is required (SSH/Telnet only)",
"folder": "Folder",
"tags": "Tags",
"pin": "Pin",
@@ -988,6 +1007,8 @@
"tunnel": "Tunnel",
"fileManager": "File Manager",
"serverStats": "Server Stats",
+ "status": "Status",
+ "statistics": "Statistics",
"hostViewer": "Host Viewer",
"enableServerStats": "Enable Server Stats",
"enableServerStatsDesc": "Enable/disable server statistics collection for this host",
@@ -1049,13 +1070,18 @@
"statusCheckEnabledDesc": "Check if the server is online or offline",
"statusCheckInterval": "Status Check Interval",
"statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)",
+ "statusChecks": "Status Checks",
+ "disableTcpPing": "Disable TCP Ping",
+ "disableTcpPingDescription": "Turn off status checks (online/offline detection) for this host",
"useGlobalStatusInterval": "Use global default",
"useGlobalMetricsInterval": "Use global default",
"usingGlobalDefault": "Using global default ({{value}}s)",
+ "metricsCollection": "Metrics Collection",
"metricsEnabled": "Enable Metrics Monitoring",
"metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics",
"metricsInterval": "Metrics Collection Interval",
"metricsIntervalDesc": "How often to collect server statistics (5s - 1h)",
+ "metricsNotAvailableForConnectionType": "Server metrics are only available for SSH hosts. TCP ping status checks can still be enabled above.",
"intervalSeconds": "seconds",
"intervalMinutes": "minutes",
"intervalValidation": "Monitoring intervals must be between 5 seconds and 1 hour (3600 seconds)",
@@ -1243,6 +1269,7 @@
"copyTunnelUrl": "Copy Tunnel URL",
"copyServerStatsUrl": "Copy Server Stats URL",
"copyDockerUrl": "Copy Docker URL",
+ "copyRemoteDesktopUrl": "Copy Remote Desktop URL",
"fullScreenUrlTooltip": "Copy URL to open this app in full-screen mode",
"notEnabled": "Docker is not enabled for this host. Enable it in Host Settings to use Docker features.",
"validating": "Validating Docker...",
@@ -1361,7 +1388,103 @@
"selectAll": "Select all",
"deselectAll": "Deselect all",
"useGlobalStatusDefault": "Use Global Default (Status)",
- "useGlobalMetricsDefault": "Use Global Default (Metrics)"
+ "useGlobalMetricsDefault": "Use Global Default (Metrics)",
+ "connectionType": "Connection Type",
+ "rdp": "RDP",
+ "vnc": "VNC",
+ "telnet": "Telnet",
+ "ssh": "SSH",
+ "remoteDesktop": "Remote Desktop",
+ "remoteDesktopSettings": "Remote Desktop Settings",
+ "domain": "Domain",
+ "securityMode": "Security Mode",
+ "ignoreCert": "Ignore Certificate",
+ "ignoreCertDesc": "Skip TLS certificate verification for this connection",
+ "displaySettings": "Display Settings",
+ "colorDepth": "Color Depth",
+ "width": "Width",
+ "height": "Height",
+ "dpi": "DPI",
+ "resizeMethod": "Resize Method",
+ "forceLossless": "Force Lossless",
+ "audioSettings": "Audio Settings",
+ "disableAudio": "Disable Audio",
+ "enableAudioInput": "Enable Audio Input",
+ "rdpPerformance": "Performance",
+ "enableWallpaper": "Enable Wallpaper",
+ "enableTheming": "Enable Theming",
+ "enableFontSmoothing": "Enable Font Smoothing",
+ "enableFullWindowDrag": "Enable Full Window Drag",
+ "enableDesktopComposition": "Enable Desktop Composition",
+ "enableMenuAnimations": "Enable Menu Animations",
+ "disableBitmapCaching": "Disable Bitmap Caching",
+ "disableOffscreenCaching": "Disable Offscreen Caching",
+ "disableGlyphCaching": "Disable Glyph Caching",
+ "enableGfx": "Enable GFX",
+ "deviceRedirection": "Device Redirection",
+ "enablePrinting": "Enable Printing",
+ "enableDrive": "Enable Drive Redirection",
+ "driveName": "Drive Name",
+ "drivePath": "Drive Path",
+ "createDrivePath": "Create Drive Path",
+ "disableDownload": "Disable Download",
+ "disableUpload": "Disable Upload",
+ "enableTouch": "Enable Touch",
+ "rdpSession": "Session",
+ "clientName": "Client Name",
+ "consoleSession": "Console Session",
+ "initialProgram": "Initial Program",
+ "serverLayout": "Server Keyboard Layout",
+ "timezone": "Timezone",
+ "gatewaySettings": "Gateway",
+ "gatewayHostname": "Gateway Hostname",
+ "gatewayPort": "Gateway Port",
+ "gatewayUsername": "Gateway Username",
+ "gatewayPassword": "Gateway Password",
+ "gatewayDomain": "Gateway Domain",
+ "remoteApp": "RemoteApp",
+ "remoteAppProgram": "Remote Application",
+ "remoteAppDir": "Remote App Directory",
+ "remoteAppArgs": "Remote App Arguments",
+ "clipboardSettings": "Clipboard",
+ "normalizeClipboard": "Normalize Clipboard",
+ "disableCopy": "Disable Copy",
+ "disablePaste": "Disable Paste",
+ "vncSettings": "VNC Settings",
+ "cursorMode": "Cursor Mode",
+ "swapRedBlue": "Swap Red/Blue",
+ "readOnly": "Read Only",
+ "recordingSettings": "Recording",
+ "recordingPath": "Recording Path",
+ "recordingName": "Recording Name",
+ "createRecordingPath": "Create Recording Path",
+ "excludeOutput": "Exclude Output",
+ "excludeMouse": "Exclude Mouse",
+ "includeKeys": "Include Keys",
+ "wakeOnLan": "Wake-on-LAN",
+ "sendWolPacket": "Send WoL Packet",
+ "wolMacAddr": "MAC Address",
+ "wolBroadcastAddr": "Broadcast Address",
+ "wolUdpPort": "UDP Port",
+ "wolWaitTime": "Wait Time (seconds)",
+ "connectionSettings": "Connection Settings",
+ "rdpOnly": "RDP only",
+ "vncOnly": "VNC only",
+ "telnetTerminalSettings": "Terminal Settings",
+ "terminalType": "Terminal Type",
+ "guacFontName": "Font Name",
+ "guacFontSize": "Font Size",
+ "guacColorScheme": "Color Scheme",
+ "guacBackspaceKey": "Backspace Key"
+ },
+ "guacamole": {
+ "connecting": "Connecting to {{type}} session...",
+ "rdpConnecting": "Connecting to RDP server...",
+ "vncConnecting": "Connecting to VNC server...",
+ "telnetConnecting": "Connecting to Telnet server...",
+ "connectionError": "Connection error",
+ "connectionFailed": "Connection failed",
+ "failedToConnect": "Failed to get connection token"
},
"terminal": {
"title": "Split Screen",
@@ -1408,6 +1531,7 @@
"connected": "Connected",
"clipboardWriteFailed": "Failed to copy to clipboard. Make sure the page is served over HTTPS or localhost.",
"clipboardReadFailed": "Failed to read from clipboard. Make sure clipboard permissions are granted.",
+ "clipboardHttpWarning": "Paste requires HTTPS. Use Ctrl+Shift+V or serve Termix over HTTPS.",
"sshConnected": "SSH connection established",
"authError": "Authentication failed: {{message}}",
"unknownError": "Unknown error occurred",
@@ -2196,7 +2320,7 @@
"terminalSyntaxHighlightingDesc": "Automatically highlight commands, paths, IPs, and log levels in terminal output",
"enableCommandPaletteShortcut": "Enable Command Palette Shortcut",
"enableCommandPaletteShortcutDesc": "Double-tap left Shift to open the Command Palette for quick access to hosts",
- "enableTerminalSessionPersistence": "Persistent Terminal Sessions",
+ "enableTerminalSessionPersistence": "Persistent Tabs/Sessions",
"enableTerminalSessionPersistenceDesc": "Maintain SSH connections when switching tabs or closing the browser (may be unstable)"
},
"user": {
diff --git a/src/main.tsx b/src/main.tsx
index 268356c2..740bf373 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -8,12 +8,13 @@ import { ThemeProvider } from "@/components/theme-provider";
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
import "./i18n/i18n";
import { isElectron } from "./ui/main-axios.ts";
-import HostManagerApp from "./ui/desktop/apps/HostManagerApp.tsx";
+import HostManagerApp from "./ui/desktop/apps/host-manager/HostManagerApp.tsx";
import TerminalApp from "./ui/desktop/apps/features/terminal/TerminalApp.tsx";
import FileManagerApp from "./ui/desktop/apps/features/file-manager/FileManagerApp.tsx";
import TunnelApp from "./ui/desktop/apps/features/tunnel/TunnelApp.tsx";
import ServerStatsApp from "./ui/desktop/apps/features/server-stats/ServerStatsApp.tsx";
import DockerApp from "./ui/desktop/apps/features/docker/DockerApp.tsx";
+import GuacamoleApp from "@/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx";
const FullscreenApp: React.FC = () => {
const searchParams = new URLSearchParams(window.location.search);
@@ -33,6 +34,10 @@ const FullscreenApp: React.FC = () => {
return ;
case "docker":
return ;
+ case "rdp":
+ case "vnc":
+ case "telnet":
+ return ;
default:
return ;
}
diff --git a/src/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts
new file mode 100644
index 00000000..8c3b03d6
--- /dev/null
+++ b/src/types/guacamole-common-js.d.ts
@@ -0,0 +1,108 @@
+declare module "guacamole-common-js" {
+ namespace Guacamole {
+ class Client {
+ constructor(tunnel: Tunnel);
+ connect(data?: string): void;
+ disconnect(): void;
+ getDisplay(): Display;
+ sendKeyEvent(pressed: number, keysym: number): void;
+ sendMouseState(state: Mouse.State): void;
+ setClipboard(stream: OutputStream, mimetype: string): void;
+ createClipboardStream(mimetype: string): OutputStream;
+ onstatechange: ((state: number) => void) | null;
+ onerror: ((error: Status) => void) | null;
+ onclipboard: ((stream: InputStream, mimetype: string) => void) | null;
+ }
+
+ class Display {
+ getElement(): HTMLElement;
+ getWidth(): number;
+ getHeight(): number;
+ scale(scale: number): void;
+ onresize: (() => void) | null;
+ }
+
+ class Tunnel {
+ onerror: ((status: Status) => void) | null;
+ onstatechange: ((state: number) => void) | null;
+ }
+
+ class WebSocketTunnel extends Tunnel {
+ constructor(url: string);
+ }
+
+ class Mouse {
+ constructor(element: HTMLElement);
+ onmousedown: ((state: Mouse.State) => void) | null;
+ onmouseup: ((state: Mouse.State) => void) | null;
+ onmousemove: ((state: Mouse.State) => void) | null;
+ onmouseout: ((state: Mouse.State) => void) | null;
+ }
+
+ namespace Mouse {
+ class State {
+ constructor(
+ x: number,
+ y: number,
+ left?: boolean,
+ middle?: boolean,
+ right?: boolean,
+ up?: boolean,
+ down?: boolean,
+ );
+ constructor(state: {
+ x: number;
+ y: number;
+ left?: boolean;
+ middle?: boolean;
+ right?: boolean;
+ up?: boolean;
+ down?: boolean;
+ });
+ x: number;
+ y: number;
+ left: boolean;
+ middle: boolean;
+ right: boolean;
+ up: boolean;
+ down: boolean;
+ }
+ }
+
+ class Keyboard {
+ constructor(element: Document | HTMLElement);
+ onkeydown: ((keysym: number) => void) | null;
+ onkeyup: ((keysym: number) => void) | null;
+ }
+
+ class Status {
+ code: number;
+ message: string;
+ isError(): boolean;
+ }
+
+ class InputStream {
+ onblob: ((data: string) => void) | null;
+ onend: (() => void) | null;
+ }
+
+ class OutputStream {
+ sendBlob(data: string): void;
+ sendEnd(): void;
+ }
+
+ class StringReader {
+ constructor(stream: InputStream);
+ ontext: ((text: string) => void) | null;
+ onend: (() => void) | null;
+ }
+
+ class StringWriter {
+ constructor(stream: OutputStream);
+ sendText(text: string): void;
+ sendEnd(): void;
+ }
+ }
+
+ export default Guacamole;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 48b81e62..0ff039ec 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -2,9 +2,21 @@ import type { Client } from "ssh2";
import type { Request } from "express";
// ============================================================================
-// SSH HOST TYPES
+// HOST TYPES (SSH, RDP, VNC, Telnet)
// ============================================================================
+export type ConnectionType = "ssh" | "rdp" | "vnc" | "telnet";
+export type SSHAuthType = "password" | "key" | "credential" | "none" | "opkssh";
+export type GuacamoleAuthType = "password" | "credential";
+
+export interface HostFeatureFlags {
+ enableTerminal: boolean; // SSH, Telnet only
+ enableTunnel: boolean; // SSH only
+ enableFileManager: boolean; // SSH only
+ enableDocker: boolean; // SSH only
+ enableRemoteDesktop: boolean; // RDP, VNC only
+}
+
export interface JumpHost {
hostId: number;
}
@@ -14,7 +26,7 @@ export interface QuickAction {
snippetId: number;
}
-export interface SSHHost {
+export interface Host {
id: number;
name: string;
ip: string;
@@ -62,6 +74,12 @@ export interface SSHHost {
socks5Password?: string;
socks5ProxyChain?: ProxyNode[];
+ connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
+ domain?: string;
+ security?: string;
+ ignoreCert?: boolean;
+ guacamoleConfig?: string | Record;
+
createdAt: string;
updatedAt: string;
@@ -87,7 +105,7 @@ export interface ProxyNode {
password?: string;
}
-export interface SSHHostData {
+export interface HostData {
name?: string;
ip: string;
port: number;
@@ -127,8 +145,18 @@ export interface SSHHostData {
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: ProxyNode[];
+
+ connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
+ domain?: string;
+ security?: string;
+ ignoreCert?: boolean;
+ guacamoleConfig?: Record | null;
+ dockerConfig?: Record | null;
}
+export type SSHHost = Host;
+export type SSHHostData = HostData;
+
export interface SSHFolder {
id: number;
userId: string;
@@ -415,12 +443,16 @@ export interface TabContextTab {
| "file_manager"
| "user_profile"
| "docker"
- | "network_graph";
+ | "network_graph"
+ | "rdp"
+ | "vnc"
+ | "telnet";
title: string;
hostConfig?: SSHHost;
terminalRef?: any;
initialTab?: string;
_updateTimestamp?: number;
+ connectionConfig?: Record;
}
export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";
diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts
index 69aaa31f..b355a4a8 100644
--- a/src/types/stats-widgets.ts
+++ b/src/types/stats-widgets.ts
@@ -57,6 +57,7 @@ export interface StatsConfig {
metricsEnabled: boolean;
metricsInterval: number;
useGlobalMetricsInterval?: boolean;
+ disableTcpPing?: boolean;
}
export const DEFAULT_STATS_CONFIG: StatsConfig = {
@@ -75,4 +76,5 @@ export const DEFAULT_STATS_CONFIG: StatsConfig = {
metricsEnabled: true,
metricsInterval: 30,
useGlobalMetricsInterval: true,
+ disableTcpPing: false,
};
diff --git a/src/ui/contexts/ServerStatusContext.tsx b/src/ui/contexts/ServerStatusContext.tsx
index 17d4e4c2..1ff6b995 100644
--- a/src/ui/contexts/ServerStatusContext.tsx
+++ b/src/ui/contexts/ServerStatusContext.tsx
@@ -5,6 +5,7 @@ import React, {
useEffect,
useCallback,
useRef,
+ useMemo,
} from "react";
import { getAllServerStatuses, getSSHHosts } from "@/ui/main-axios";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
@@ -71,7 +72,13 @@ export function ServerStatusProvider({
}
});
- setEnabledHostIds(enabled);
+ setEnabledHostIds((prev) => {
+ if (prev.size !== enabled.size) return enabled;
+ for (const id of enabled) {
+ if (!prev.has(id)) return enabled;
+ }
+ return prev;
+ });
return enabled;
} catch (error) {
return new Set();
@@ -125,14 +132,19 @@ export function ServerStatusProvider({
}
}, [isAuthenticated]);
+ const stableEnabledHostIds = useMemo(
+ () => enabledHostIds,
+ [[...enabledHostIds].sort().join(",")],
+ );
+
const getStatus = useCallback(
(hostId: number): StatusValue => {
- if (!enabledHostIds.has(hostId)) {
+ if (!stableEnabledHostIds.has(hostId)) {
return "offline";
}
return statuses.get(hostId)?.status || "degraded";
},
- [statuses, enabledHostIds],
+ [statuses, stableEnabledHostIds],
);
useEffect(() => {
diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx
index e9411c95..333ae776 100644
--- a/src/ui/desktop/DesktopApp.tsx
+++ b/src/ui/desktop/DesktopApp.tsx
@@ -156,9 +156,8 @@ function AppContent({
if (hostIdentifier) {
const openTerminal = async () => {
try {
- const { getSSHHostById, getSSHHosts } = await import(
- "@/ui/main-axios.ts"
- );
+ const { getSSHHostById, getSSHHosts } =
+ await import("@/ui/main-axios.ts");
let host = null;
if (/^\d+$/.test(hostIdentifier)) {
@@ -282,6 +281,9 @@ function AppContent({
currentTabData?.type === "terminal" ||
currentTabData?.type === "server_stats" ||
currentTabData?.type === "file_manager" ||
+ currentTabData?.type === "rdp" ||
+ currentTabData?.type === "vnc" ||
+ currentTabData?.type === "telnet" ||
currentTabData?.type === "tunnel" ||
currentTabData?.type === "docker" ||
currentTabData?.type === "network_graph";
diff --git a/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx b/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx
index dbc2b3cf..cf6c0094 100644
--- a/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx
+++ b/src/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx
@@ -17,7 +17,10 @@ import {
updatePasswordResetAllowed,
getGlobalMonitoringSettings,
updateGlobalMonitoringSettings,
+ getGuacamoleSettings,
+ updateGuacamoleSettings,
} from "@/ui/main-axios.ts";
+import { Button } from "@/components/ui/button.tsx";
interface GeneralSettingsTabProps {
allowRegistration: boolean;
@@ -51,9 +54,10 @@ export function GeneralSettingsTab({
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
const [passwordResetLoading, setPasswordResetLoading] = React.useState(false);
- // Global monitoring defaults
const [statusInterval, setStatusInterval] = React.useState(60);
const [metricsInterval, setMetricsInterval] = React.useState(30);
+ const [statusInputValue, setStatusInputValue] = React.useState("60");
+ const [metricsInputValue, setMetricsInputValue] = React.useState("30");
const [statusUnit, setStatusUnit] = React.useState<"seconds" | "minutes">(
"seconds",
);
@@ -62,59 +66,101 @@ export function GeneralSettingsTab({
);
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
+ const [guacEnabled, setGuacEnabled] = React.useState(true);
+ const [guacUrl, setGuacUrl] = React.useState("guacd:4822");
+ const [guacLoading, setGuacLoading] = React.useState(false);
+
React.useEffect(() => {
- getGlobalMonitoringSettings()
+ getGuacamoleSettings()
.then((data) => {
- setStatusInterval(data.statusCheckInterval);
- setMetricsInterval(data.metricsInterval);
+ setGuacEnabled(data.enabled);
+ setGuacUrl(data.url);
})
.catch(() => {
- // Use defaults silently
+ toast.error(t("admin.failedToLoadGuacamoleSettings"));
});
- }, []);
+ }, [t]);
- const saveMonitoringDebounce = React.useRef(null);
+ const saveGuacDebounce = React.useRef(null);
- const saveMonitoringSettings = React.useCallback(
- (newStatus: number, newMetrics: number) => {
- if (saveMonitoringDebounce.current) {
- clearTimeout(saveMonitoringDebounce.current);
+ const saveGuacSettings = React.useCallback(
+ (newEnabled: boolean, newUrl: string) => {
+ if (saveGuacDebounce.current) {
+ clearTimeout(saveGuacDebounce.current);
}
- saveMonitoringDebounce.current = setTimeout(async () => {
- setMonitoringLoading(true);
+ saveGuacDebounce.current = setTimeout(async () => {
+ setGuacLoading(true);
try {
- await updateGlobalMonitoringSettings({
- statusCheckInterval: newStatus,
- metricsInterval: newMetrics,
- });
- toast.success(t("admin.globalSettingsSaved"));
- } catch (error) {
- const errorMessage =
- error instanceof Error
- ? error.message
- : t("admin.failedToSaveGlobalSettings");
- toast.error(errorMessage);
+ await updateGuacamoleSettings({ enabled: newEnabled, url: newUrl });
+ toast.success(t("admin.guacamoleSettingsSaved"));
+ } catch {
+ toast.error(t("admin.failedToSaveGuacamoleSettings"));
} finally {
- setMonitoringLoading(false);
+ setGuacLoading(false);
}
}, 800);
},
[t],
);
- const handleStatusIntervalChange = (value: string) => {
- const num = parseInt(value) || 0;
+ React.useEffect(() => {
+ getGlobalMonitoringSettings()
+ .then((data) => {
+ setStatusInterval(data.statusCheckInterval);
+ setMetricsInterval(data.metricsInterval);
+ setStatusInputValue(String(data.statusCheckInterval));
+ setMetricsInputValue(String(data.metricsInterval));
+ })
+ .catch(() => {
+ // Use defaults silently
+ });
+ }, []);
+
+ const saveMonitoringSettings = React.useCallback(
+ async (newStatus: number, newMetrics: number) => {
+ setMonitoringLoading(true);
+ try {
+ await updateGlobalMonitoringSettings({
+ statusCheckInterval: newStatus,
+ metricsInterval: newMetrics,
+ });
+ toast.success(t("admin.globalSettingsSaved"));
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error
+ ? error.message
+ : t("admin.failedToSaveGlobalSettings");
+ toast.error(errorMessage);
+ } finally {
+ setMonitoringLoading(false);
+ }
+ },
+ [t],
+ );
+
+ const handleStatusBlur = () => {
+ const num = parseInt(statusInputValue) || 0;
const seconds = statusUnit === "minutes" ? num * 60 : num;
const clamped = Math.max(5, Math.min(3600, seconds));
setStatusInterval(clamped);
+ setStatusInputValue(
+ statusUnit === "minutes"
+ ? String(Math.round(clamped / 60))
+ : String(clamped),
+ );
saveMonitoringSettings(clamped, metricsInterval);
};
- const handleMetricsIntervalChange = (value: string) => {
- const num = parseInt(value) || 0;
+ const handleMetricsBlur = () => {
+ const num = parseInt(metricsInputValue) || 0;
const seconds = metricsUnit === "minutes" ? num * 60 : num;
const clamped = Math.max(5, Math.min(3600, seconds));
setMetricsInterval(clamped);
+ setMetricsInputValue(
+ metricsUnit === "minutes"
+ ? String(Math.round(clamped / 60))
+ : String(clamped),
+ );
saveMonitoringSettings(statusInterval, clamped);
};
@@ -244,12 +290,9 @@ export function GeneralSettingsTab({
handleStatusIntervalChange(e.target.value)}
+ value={statusInputValue}
+ onChange={(e) => setStatusInputValue(e.target.value)}
+ onBlur={handleStatusBlur}
disabled={monitoringLoading}
className="flex-1"
/>
@@ -257,6 +300,11 @@ export function GeneralSettingsTab({
value={statusUnit}
onValueChange={(value: "seconds" | "minutes") => {
setStatusUnit(value);
+ setStatusInputValue(
+ value === "minutes"
+ ? String(Math.round(statusInterval / 60))
+ : String(statusInterval),
+ );
}}
>
@@ -280,12 +328,9 @@ export function GeneralSettingsTab({
handleMetricsIntervalChange(e.target.value)}
+ value={metricsInputValue}
+ onChange={(e) => setMetricsInputValue(e.target.value)}
+ onBlur={handleMetricsBlur}
disabled={monitoringLoading}
className="flex-1"
/>
@@ -293,6 +338,11 @@ export function GeneralSettingsTab({
value={metricsUnit}
onValueChange={(value: "seconds" | "minutes") => {
setMetricsUnit(value);
+ setMetricsInputValue(
+ value === "minutes"
+ ? String(Math.round(metricsInterval / 60))
+ : String(metricsInterval),
+ );
}}
>
@@ -311,6 +361,55 @@ export function GeneralSettingsTab({
+
+
+
+ {t("admin.guacamoleIntegration")}
+
+
+ {t("admin.guacamoleIntegrationDesc")}
+
+
+ window.open("https://docs.termix.site/remote-desktop", "_blank")
+ }
+ >
+ {t("common.documentation")}
+
+
+ {
+ const val = checked === true;
+ setGuacEnabled(val);
+ saveGuacSettings(val, guacUrl);
+ }}
+ disabled={guacLoading}
+ />
+ {t("admin.enableGuacamole")}
+
+ {guacEnabled && (
+
+
{t("admin.guacdUrl")}
+
{
+ setGuacUrl(e.target.value);
+ saveGuacSettings(guacEnabled, e.target.value);
+ }}
+ />
+
+ {t("admin.guacdUrlNote")}
+
+
+ )}
+
);
}
diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx
index 107020bb..84c419a8 100644
--- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx
+++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx
@@ -16,6 +16,9 @@ import {
User,
Github,
Terminal,
+ Monitor,
+ Eye,
+ MessagesSquare,
FolderOpen,
Pencil,
EllipsisVertical,
@@ -27,8 +30,14 @@ import { BiMoney, BiSupport } from "react-icons/bi";
import { BsDiscord } from "react-icons/bs";
import { GrUpdate } from "react-icons/gr";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
-import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
+import {
+ getRecentActivity,
+ getSSHHosts,
+ getGuacamoleToken,
+ logActivity,
+} from "@/ui/main-axios.ts";
import type { RecentActivityItem } from "@/ui/main-axios.ts";
+import { toast } from "sonner";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import {
DropdownMenu,
@@ -62,6 +71,16 @@ interface SSHHost {
statsConfig?: string;
createdAt: string;
updatedAt: string;
+ connectionType?: "ssh" | "rdp" | "vnc" | "telnet";
+ domain?: string;
+ security?: string;
+ ignoreCert?: boolean;
+ guacamoleConfig?: any;
+ showTerminalInSidebar?: boolean;
+ showFileManagerInSidebar?: boolean;
+ showTunnelInSidebar?: boolean;
+ showDockerInSidebar?: boolean;
+ showServerStatsInSidebar?: boolean;
}
export function CommandPalette({
@@ -204,10 +223,60 @@ export function CommandPalette({
setIsOpen(false);
};
- const handleHostTerminalClick = (host: SSHHost) => {
+ const handleHostTerminalClick = async (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
+
+ if (
+ host.connectionType === "rdp" ||
+ host.connectionType === "vnc" ||
+ host.connectionType === "telnet"
+ ) {
+ try {
+ const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
+ const result = await getGuacamoleToken({
+ protocol,
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ ignoreCert: host.ignoreCert,
+ guacamoleConfig: host.guacamoleConfig as any,
+ });
+
+ addTab({
+ type: protocol,
+ title,
+ hostConfig: host,
+ connectionConfig: {
+ token: result.token,
+ protocol,
+ type: protocol,
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ "ignore-cert": host.ignoreCert,
+ },
+ });
+
+ try {
+ await logActivity(protocol, host.id, title);
+ } catch (err) {
+ console.warn(`Failed to log ${protocol} activity:`, err);
+ }
+ } catch (err) {
+ console.error("Failed to get Guacamole token:", err);
+ }
+ setIsOpen(false);
+ return;
+ }
+
addTab({ type: "terminal", title, hostConfig: host });
setIsOpen(false);
};
@@ -340,6 +409,9 @@ export function CommandPalette({
shouldShowMetrics = true;
}
+ const isSSH =
+ !host.connectionType || host.connectionType === "ssh";
+
let hasTunnelConnections = false;
try {
const tunnelConnections = Array.isArray(
@@ -356,13 +428,18 @@ export function CommandPalette({
const visibleButtons = [
host.enableTerminal && (host.showTerminalInSidebar ?? true),
- host.enableFileManager &&
+ isSSH &&
+ host.enableFileManager &&
(host.showFileManagerInSidebar ?? false),
- host.enableTunnel &&
+ isSSH &&
+ host.enableTunnel &&
hasTunnelConnections &&
(host.showTunnelInSidebar ?? false),
- host.enableDocker && (host.showDockerInSidebar ?? false),
- shouldShowMetrics &&
+ isSSH &&
+ host.enableDocker &&
+ (host.showDockerInSidebar ?? false),
+ isSSH &&
+ shouldShowMetrics &&
(host.showServerStatsInSidebar ?? false),
].filter(Boolean).length;
@@ -395,11 +472,20 @@ export function CommandPalette({
handleHostTerminalClick(host);
}}
>
-
+ {host.connectionType === "rdp" ? (
+
+ ) : host.connectionType === "vnc" ? (
+
+ ) : host.connectionType === "telnet" ? (
+
+ ) : (
+
+ )}
)}
- {host.enableFileManager &&
+ {isSSH &&
+ host.enableFileManager &&
(host.showFileManagerInSidebar ?? false) && (
)}
- {host.enableTunnel &&
+ {isSSH &&
+ host.enableTunnel &&
hasTunnelConnections &&
(host.showTunnelInSidebar ?? false) && (
)}
- {host.enableDocker &&
+ {isSSH &&
+ host.enableDocker &&
(host.showDockerInSidebar ?? false) && (
)}
- {shouldShowMetrics &&
+ {isSSH &&
+ shouldShowMetrics &&
(host.showServerStatsInSidebar ?? false) && (
-
+ {host.connectionType === "rdp" ? (
+
+ ) : host.connectionType === "vnc" ? (
+
+ ) : host.connectionType === "telnet" ? (
+
+ ) : (
+
+ )}
{t("hosts.openTerminal")}
)}
- {shouldShowMetrics &&
+ {isSSH &&
+ shouldShowMetrics &&
!(host.showServerStatsInSidebar ?? false) && (
{
@@ -505,7 +603,8 @@ export function CommandPalette({
)}
- {host.enableFileManager &&
+ {isSSH &&
+ host.enableFileManager &&
!(host.showFileManagerInSidebar ?? false) && (
{
@@ -520,7 +619,8 @@ export function CommandPalette({
)}
- {host.enableTunnel &&
+ {isSSH &&
+ host.enableTunnel &&
hasTunnelConnections &&
!(host.showTunnelInSidebar ?? false) && (
)}
- {host.enableDocker &&
+ {isSSH &&
+ host.enableDocker &&
!(host.showDockerInSidebar ?? false) && (
{
diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx
index 0e968a5b..55ab28b1 100644
--- a/src/ui/desktop/apps/dashboard/Dashboard.tsx
+++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx
@@ -15,6 +15,7 @@ import {
getServerMetricsById,
registerMetricsViewer,
sendMetricsHeartbeat,
+ getGuacamoleToken,
type RecentActivityItem,
} from "@/ui/main-axios.ts";
import { useSidebar } from "@/components/ui/sidebar.tsx";
@@ -386,6 +387,108 @@ export function Dashboard({
title: item.hostName,
hostConfig: host,
});
+ } else if (item.type === "telnet") {
+ getGuacamoleToken({
+ protocol: "telnet",
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ ignoreCert: host.ignoreCert,
+ guacamoleConfig: host.guacamoleConfig as any,
+ })
+ .then((result) => {
+ addTab({
+ type: "telnet",
+ title: item.hostName,
+ hostConfig: host,
+ connectionConfig: {
+ token: result.token,
+ protocol: "telnet",
+ type: "telnet",
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ "ignore-cert": host.ignoreCert,
+ },
+ });
+ })
+ .catch((error) => {
+ console.error("Failed to get telnet token:", error);
+ });
+ } else if (item.type === "vnc") {
+ getGuacamoleToken({
+ protocol: "vnc",
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ ignoreCert: host.ignoreCert,
+ guacamoleConfig: host.guacamoleConfig as any,
+ })
+ .then((result) => {
+ addTab({
+ type: "vnc",
+ title: item.hostName,
+ hostConfig: host,
+ connectionConfig: {
+ token: result.token,
+ protocol: "vnc",
+ type: "vnc",
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ "ignore-cert": host.ignoreCert,
+ },
+ });
+ })
+ .catch((error) => {
+ console.error("Failed to get vnc token:", error);
+ });
+ } else if (item.type === "rdp") {
+ getGuacamoleToken({
+ protocol: "rdp",
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ ignoreCert: host.ignoreCert,
+ guacamoleConfig: host.guacamoleConfig as any,
+ })
+ .then((result) => {
+ addTab({
+ type: "rdp",
+ title: item.hostName,
+ hostConfig: host,
+ connectionConfig: {
+ token: result.token,
+ protocol: "rdp",
+ type: "rdp",
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ "ignore-cert": host.ignoreCert,
+ },
+ });
+ })
+ .catch((error) => {
+ console.error("Failed to get rdp token:", error);
+ });
}
});
};
diff --git a/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx b/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
index 496a3564..8a92c950 100644
--- a/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
+++ b/src/ui/desktop/apps/dashboard/cards/RecentActivityCard.tsx
@@ -8,6 +8,10 @@ import {
Server,
ArrowDownUp,
Container,
+ Monitor,
+ Eye,
+ MessagesSquare,
+ Network,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { type RecentActivityItem } from "@/ui/main-axios";
@@ -83,6 +87,12 @@ export function RecentActivityCard({
) : item.type === "docker" ? (
+ ) : item.type === "telnet" ? (
+
+ ) : item.type === "vnc" ? (
+
+ ) : item.type === "rdp" ? (
+
) : (
)}
diff --git a/src/ui/desktop/apps/features/docker/components/ConsoleTerminal.tsx b/src/ui/desktop/apps/features/docker/components/ConsoleTerminal.tsx
index cef465e5..97387a8a 100644
--- a/src/ui/desktop/apps/features/docker/components/ConsoleTerminal.tsx
+++ b/src/ui/desktop/apps/features/docker/components/ConsoleTerminal.tsx
@@ -249,7 +249,7 @@ export function ConsoleTerminal({
window.location.port === "");
const baseWsUrl = isDev
- ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30008`
+ ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
: isElectronApp
? (() => {
const baseUrl =
diff --git a/src/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx b/src/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx
new file mode 100644
index 00000000..22715dcb
--- /dev/null
+++ b/src/ui/desktop/apps/features/guacamole/GuacamoleApp.tsx
@@ -0,0 +1,103 @@
+import React, { useState, useEffect } from "react";
+import { GuacamoleDisplay } from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
+import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
+import { getGuacamoleTokenFromHost } from "@/ui/main-axios.ts";
+import { useTranslation } from "react-i18next";
+
+interface GuacamoleAppProps {
+ hostId?: string;
+}
+
+const GuacamoleApp: React.FC = ({ hostId }) => {
+ return (
+
+ {(hostConfig, loading) => {
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!hostConfig) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }}
+
+ );
+};
+
+interface GuacamoleAppInnerProps {
+ hostId: number;
+ hostConfig: any;
+}
+
+const GuacamoleAppInner: React.FC = ({
+ hostId,
+ hostConfig,
+}) => {
+ const { t } = useTranslation();
+ const [token, setToken] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ getGuacamoleTokenFromHost(hostId)
+ .then((result) => setToken(result.token))
+ .catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
+ }, [hostId]);
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!token) {
+ return (
+
+
+
+
+ {t("guacamole.connecting", {
+ type: (hostConfig.connectionType || "remote").toUpperCase(),
+ })}
+
+
+
+ );
+ }
+
+ const protocol = hostConfig.connectionType as "rdp" | "vnc" | "telnet";
+
+ return (
+
+
+
+ );
+};
+
+export default GuacamoleApp;
diff --git a/src/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx b/src/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx
new file mode 100644
index 00000000..9ce3aec0
--- /dev/null
+++ b/src/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx
@@ -0,0 +1,385 @@
+import {
+ useEffect,
+ useRef,
+ useState,
+ useImperativeHandle,
+ forwardRef,
+ useCallback,
+} from "react";
+import Guacamole from "guacamole-common-js";
+import { useTranslation } from "react-i18next";
+import { getCookie, isElectron } from "@/ui/main-axios.ts";
+import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
+
+export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
+
+export interface GuacamoleConnectionConfig {
+ token?: string;
+ protocol?: GuacamoleConnectionType;
+ type?: GuacamoleConnectionType;
+ hostname?: string;
+ port?: number;
+ username?: string;
+ password?: string;
+ domain?: string;
+ width?: number;
+ height?: number;
+ dpi?: number;
+ [key: string]: unknown;
+}
+
+export interface GuacamoleDisplayHandle {
+ disconnect: () => void;
+ sendKey: (keysym: number, pressed: boolean) => void;
+ sendMouse: (x: number, y: number, buttonMask: number) => void;
+ setClipboard: (data: string) => void;
+}
+
+interface GuacamoleDisplayProps {
+ connectionConfig: GuacamoleConnectionConfig;
+ isVisible: boolean;
+ onConnect?: () => void;
+ onDisconnect?: () => void;
+ onError?: (error: string) => void;
+}
+
+const isDev = import.meta.env.DEV;
+
+export const GuacamoleDisplay = forwardRef<
+ GuacamoleDisplayHandle,
+ GuacamoleDisplayProps
+>(function GuacamoleDisplay(
+ { connectionConfig, isVisible, onConnect, onDisconnect, onError },
+ ref,
+) {
+ const { t } = useTranslation();
+ const containerRef = useRef(null);
+ const displayRef = useRef(null);
+ const clientRef = useRef(null);
+ const scaleRef = useRef(1);
+ const resizeTimeoutRef = useRef(null);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [isReady, setIsReady] = useState(false);
+
+ useImperativeHandle(ref, () => ({
+ disconnect: () => {
+ if (clientRef.current) {
+ clientRef.current.disconnect();
+ }
+ },
+ sendKey: (keysym: number, pressed: boolean) => {
+ if (clientRef.current) {
+ clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
+ }
+ },
+ sendMouse: (x: number, y: number, buttonMask: number) => {
+ if (clientRef.current) {
+ clientRef.current.sendMouseState(
+ new Guacamole.Mouse.State({
+ x,
+ y,
+ left: !!(buttonMask & 1),
+ middle: !!(buttonMask & 2),
+ right: !!(buttonMask & 4),
+ }),
+ );
+ }
+ },
+ setClipboard: (data: string) => {
+ if (clientRef.current) {
+ const stream = clientRef.current.createClipboardStream("text/plain");
+ const writer = new Guacamole.StringWriter(stream);
+ writer.sendText(data);
+ writer.sendEnd();
+ }
+ },
+ }));
+
+ const getWebSocketUrl = useCallback(
+ async (
+ containerWidth: number,
+ containerHeight: number,
+ ): Promise => {
+ try {
+ let token: string;
+
+ if (connectionConfig.token) {
+ token = connectionConfig.token;
+ } else {
+ const jwtToken = getCookie("jwt");
+ if (!jwtToken) {
+ onError?.("Authentication required");
+ return null;
+ }
+
+ const baseUrl = isDev
+ ? "http://localhost:30001"
+ : isElectron()
+ ? (window as { configuredServerUrl?: string })
+ .configuredServerUrl || "http://127.0.0.1:30001"
+ : `${window.location.origin}`;
+
+ const response = await fetch(`${baseUrl}/guacamole/token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${jwtToken}`,
+ },
+ body: JSON.stringify(connectionConfig),
+ credentials: "include",
+ });
+
+ if (!response.ok) {
+ const err = await response.json();
+ throw new Error(err.error || "Failed to get connection token");
+ }
+
+ const data = await response.json();
+ token = data.token;
+ }
+
+ const width = connectionConfig.width || containerWidth || 1280;
+ const height = connectionConfig.height || containerHeight || 720;
+ const dpi = connectionConfig.dpi || 96;
+
+ const wsBase = isDev
+ ? `ws://localhost:30008`
+ : isElectron()
+ ? (() => {
+ const base =
+ (window as { configuredServerUrl?: string })
+ .configuredServerUrl || "http://127.0.0.1:30001";
+ return `${base.startsWith("https://") ? "wss://" : "ws://"}${base.replace(/^https?:\/\//, "")}/guacamole/websocket/`;
+ })()
+ : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
+
+ return `${wsBase}?token=${encodeURIComponent(token)}&width=${width}&height=${height}&dpi=${dpi}`;
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown error";
+ onError?.(errorMessage);
+ return null;
+ }
+ },
+ [connectionConfig, onError],
+ );
+
+ const rescaleDisplay = useCallback((immediate: boolean = false) => {
+ if (!clientRef.current || !containerRef.current) return;
+
+ const performRescale = () => {
+ if (!clientRef.current || !containerRef.current) return;
+
+ const display = clientRef.current.getDisplay();
+ const cWidth = containerRef.current.clientWidth;
+ const cHeight = containerRef.current.clientHeight;
+ const displayWidth = display.getWidth();
+ const displayHeight = display.getHeight();
+
+ if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) {
+ const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight);
+ scaleRef.current = scale;
+ display.scale(scale);
+ }
+ };
+
+ if (immediate) {
+ performRescale();
+ } else {
+ if (resizeTimeoutRef.current) {
+ clearTimeout(resizeTimeoutRef.current);
+ }
+ resizeTimeoutRef.current = setTimeout(performRescale, 200);
+ }
+ }, []);
+
+ const connect = useCallback(async () => {
+ if (isConnectingRef.current) return;
+ isConnectingRef.current = true;
+ setIsConnecting(true);
+ setIsReady(false);
+
+ let containerWidth = containerRef.current?.clientWidth || 0;
+ let containerHeight = containerRef.current?.clientHeight || 0;
+
+ if (containerWidth < 100 || containerHeight < 100) {
+ containerWidth = 1280;
+ containerHeight = 720;
+ }
+
+ const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
+ if (!wsUrl) {
+ isConnectingRef.current = false;
+ setIsConnecting(false);
+ return;
+ }
+
+ const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
+ const client = new Guacamole.Client(tunnel);
+ clientRef.current = client;
+
+ const display = client.getDisplay();
+ const displayElement = display.getElement();
+
+ if (displayRef.current) {
+ displayRef.current.innerHTML = "";
+ displayRef.current.appendChild(displayElement);
+ }
+
+ display.onresize = () => {
+ rescaleDisplay(true);
+ setIsReady(true);
+ };
+
+ const mouse = new Guacamole.Mouse(displayElement);
+ const sendMouseState = (state: Guacamole.Mouse.State) => {
+ const scale = scaleRef.current;
+ const adjustedX = Math.round(state.x / scale);
+ const adjustedY = Math.round(state.y / scale);
+
+ const adjustedState = new Guacamole.Mouse.State(
+ adjustedX,
+ adjustedY,
+ state.left,
+ state.middle,
+ state.right,
+ state.up,
+ state.down,
+ ) as Guacamole.Mouse.State;
+
+ client.sendMouseState(adjustedState);
+ };
+ mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
+
+ const keyboard = new Guacamole.Keyboard(document);
+ keyboard.onkeydown = (keysym: number) => {
+ client.sendKeyEvent(1, keysym);
+ };
+ keyboard.onkeyup = (keysym: number) => {
+ client.sendKeyEvent(0, keysym);
+ };
+
+ client.onstatechange = (state: number) => {
+ switch (state) {
+ case 0:
+ break;
+ case 1:
+ setIsConnecting(true);
+ break;
+ case 2:
+ break;
+ case 3:
+ setIsConnecting(false);
+ onConnect?.();
+ break;
+ case 4:
+ break;
+ case 5:
+ setIsConnecting(false);
+ setIsReady(false);
+ keyboard.onkeydown = null;
+ keyboard.onkeyup = null;
+ onDisconnect?.();
+ break;
+ }
+ };
+
+ client.onerror = (error: Guacamole.Status) => {
+ const errorMessage = error.message || "Connection error";
+ setIsConnecting(false);
+ setIsReady(false);
+ onError?.(errorMessage);
+ };
+
+ client.onclipboard = (stream: Guacamole.InputStream, mimetype: string) => {
+ if (mimetype === "text/plain") {
+ const reader = new Guacamole.StringReader(stream);
+ let data = "";
+ reader.ontext = (text: string) => {
+ data += text;
+ };
+ reader.onend = () => {
+ navigator.clipboard.writeText(data).catch(() => {});
+ };
+ }
+ };
+
+ client.connect();
+ }, [getWebSocketUrl, onConnect, onDisconnect, onError, rescaleDisplay]);
+
+ const hasInitiatedRef = useRef(false);
+ const isMountedRef = useRef(false);
+ const isConnectingRef = useRef(false);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+
+ if (isVisible && !hasInitiatedRef.current) {
+ hasInitiatedRef.current = true;
+ requestAnimationFrame(() => {
+ if (isMountedRef.current) {
+ connect();
+ }
+ });
+ }
+ }, [isVisible, connect]);
+
+ useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ hasInitiatedRef.current = false;
+ isConnectingRef.current = false;
+ if (resizeTimeoutRef.current) {
+ clearTimeout(resizeTimeoutRef.current);
+ }
+ if (clientRef.current) {
+ clientRef.current.disconnect();
+ clientRef.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ const resizeObserver = new ResizeObserver(() => {
+ rescaleDisplay(false);
+ });
+
+ resizeObserver.observe(containerRef.current);
+
+ const initialTimeout = setTimeout(() => rescaleDisplay(true), 100);
+
+ return () => {
+ resizeObserver.disconnect();
+ clearTimeout(initialTimeout);
+ };
+ }, [rescaleDisplay]);
+
+ const connectingMessage = t("guacamole.connecting", {
+ type: (
+ connectionConfig.protocol ||
+ connectionConfig.type ||
+ "remote"
+ ).toUpperCase(),
+ });
+
+ return (
+
+ );
+});
diff --git a/src/ui/desktop/apps/features/terminal/SudoPasswordPopup.tsx b/src/ui/desktop/apps/features/terminal/SudoPasswordPopup.tsx
new file mode 100644
index 00000000..f2dab957
--- /dev/null
+++ b/src/ui/desktop/apps/features/terminal/SudoPasswordPopup.tsx
@@ -0,0 +1,81 @@
+import { useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { KeyRound } from "lucide-react";
+import { Button } from "@/components/ui/button.tsx";
+
+interface SudoPasswordPopupProps {
+ isOpen: boolean;
+ hostPassword: string;
+ backgroundColor: string;
+ onConfirm: (password: string) => void;
+ onDismiss: () => void;
+}
+
+export function SudoPasswordPopup({
+ isOpen,
+ hostPassword,
+ backgroundColor,
+ onConfirm,
+ onDismiss,
+}: SudoPasswordPopupProps) {
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ onConfirm(hostPassword);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ onDismiss();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown, true);
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
+ }, [isOpen, onConfirm, onDismiss, hostPassword]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
+
+ {t("terminal.sudoPasswordPopupTitle", "Insert password?")}
+
+
+ {t(
+ "terminal.sudoPasswordPopupHint",
+ "Press Enter to insert, Esc to dismiss",
+ )}
+
+
+
+
+
+ {t("terminal.sudoPasswordPopupDismiss", "Dismiss")}
+
+ onConfirm(hostPassword)}
+ >
+ {t("terminal.sudoPasswordPopupConfirm", "Insert")}
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/features/terminal/Terminal.tsx b/src/ui/desktop/apps/features/terminal/Terminal.tsx
index cfcb45a1..1a6fff80 100644
--- a/src/ui/desktop/apps/features/terminal/Terminal.tsx
+++ b/src/ui/desktop/apps/features/terminal/Terminal.tsx
@@ -1499,7 +1499,10 @@ const TerminalInner = forwardRef(
return await navigator.clipboard.readText();
}
} catch {
- toast.error(t("terminal.clipboardReadFailed"));
+ // fall through
+ }
+ if (window.location.protocol !== "https:" && !isElectron()) {
+ toast.error(t("terminal.clipboardHttpWarning"));
}
return "";
}
@@ -1679,19 +1682,15 @@ const TerminalInner = forwardRef(
if (!getUseRightClickCopyPaste()) return;
e.preventDefault();
e.stopPropagation();
- try {
- if (terminal.hasSelection()) {
- const selection = terminal.getSelection();
- if (selection) {
- await writeTextToClipboard(selection);
- terminal.clearSelection();
- }
- } else {
- const pasteText = await readTextFromClipboard();
- if (pasteText) terminal.paste(pasteText);
+ if (terminal.hasSelection()) {
+ const selection = terminal.getSelection();
+ if (selection) {
+ await writeTextToClipboard(selection);
+ terminal.clearSelection();
}
- } catch (error) {
- console.error("Terminal operation failed:", error);
+ } else {
+ const text = await readTextFromClipboard();
+ if (text) terminal.paste(text);
}
};
element?.addEventListener("contextmenu", handleContextMenu);
@@ -1832,6 +1831,21 @@ const TerminalInner = forwardRef(
}
}
+ if (
+ e.ctrlKey &&
+ !e.shiftKey &&
+ !e.altKey &&
+ !e.metaKey &&
+ e.key.toLowerCase() === "v"
+ ) {
+ e.preventDefault();
+ e.stopPropagation();
+ readTextFromClipboard().then((text) => {
+ if (text) terminal.paste(text);
+ });
+ return false;
+ }
+
if (e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) {
const key = e.key.toLowerCase();
const blockedKeys = ["w", "t", "n", "q"];
diff --git a/src/ui/desktop/apps/HostManagerApp.tsx b/src/ui/desktop/apps/host-manager/HostManagerApp.tsx
similarity index 93%
rename from src/ui/desktop/apps/HostManagerApp.tsx
rename to src/ui/desktop/apps/host-manager/HostManagerApp.tsx
index 6feb771b..5d5e746f 100644
--- a/src/ui/desktop/apps/HostManagerApp.tsx
+++ b/src/ui/desktop/apps/host-manager/HostManagerApp.tsx
@@ -1,4 +1,4 @@
-import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager";
+import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager.tsx";
import React from "react";
const HostManagerApp: React.FC = () => {
diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx
index 9d2eda6e..f960231f 100644
--- a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx
+++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx
@@ -53,6 +53,7 @@ import {
revokeHostAccess,
getSSHHostById,
notifyHostCreatedOrUpdated,
+ getGuacamoleSettings,
type Role,
type AccessRecord,
} from "@/ui/main-axios.ts";
@@ -130,7 +131,9 @@ import { HostDockerTab } from "./tabs/HostDockerTab";
import { HostTunnelTab } from "./tabs/HostTunnelTab";
import { HostFileManagerTab } from "./tabs/HostFileManagerTab";
import { HostStatisticsTab } from "./tabs/HostStatisticsTab";
+import { HostStatusTab } from "./tabs/HostStatusTab";
import { HostSharingTab } from "./tabs/HostSharingTab";
+import { HostRemoteDesktopTab } from "./tabs/HostRemoteDesktopTab";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface User {
@@ -176,11 +179,18 @@ export function HostManagerEditor({
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState(null);
+ const [guacEnabled, setGuacEnabled] = useState(true);
useEffect(() => {
setFormError(null);
}, [activeTab]);
+ useEffect(() => {
+ getGuacamoleSettings()
+ .then((data) => setGuacEnabled(data.enabled))
+ .catch(() => {});
+ }, []);
+
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
"seconds" | "minutes"
>("seconds");
@@ -265,10 +275,11 @@ export function HostManagerEditor({
const formSchema = z
.object({
+ connectionType: z.enum(["ssh", "rdp", "vnc", "telnet"]).default("ssh"),
name: z.string().optional(),
- ip: z.string().min(1),
+ ip: z.string().min(1, t("hosts.ipRequired", "IP address is required")),
port: z.coerce.number().min(1).max(65535),
- username: z.string().min(1),
+ username: z.string().optional(),
folder: z.string().optional(),
tags: z.array(z.string().min(1)).default([]),
pin: z.boolean().default(false),
@@ -350,6 +361,7 @@ export function HostManagerEditor({
metricsEnabled: z.boolean().default(true),
metricsInterval: z.number().min(5).max(3600).default(30),
useGlobalMetricsInterval: z.boolean().default(true),
+ disableTcpPing: z.boolean().default(false),
})
.default({
enabledWidgets: [
@@ -369,6 +381,7 @@ export function HostManagerEditor({
metricsEnabled: true,
metricsInterval: 30,
useGlobalMetricsInterval: true,
+ disableTcpPing: false,
}),
terminalConfig: z
.object({
@@ -436,6 +449,10 @@ export function HostManagerEditor({
)
.optional(),
enableDocker: z.boolean().default(false),
+ domain: z.string().optional(),
+ security: z.string().optional(),
+ ignoreCert: z.boolean().default(true),
+ guacamoleConfig: z.record(z.string(), z.unknown()).optional(),
showTerminalInSidebar: z.boolean().default(true),
showFileManagerInSidebar: z.boolean().default(false),
showTunnelInSidebar: z.boolean().default(false),
@@ -443,6 +460,20 @@ export function HostManagerEditor({
showServerStatsInSidebar: z.boolean().default(false),
})
.superRefine((data, ctx) => {
+ if (data.connectionType !== "ssh") {
+ return;
+ }
+
+ if (!data.username || data.username.trim() === "") {
+ if (data.authType !== "credential") {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: t("hosts.usernameRequired", "Username is required"),
+ path: ["username"],
+ });
+ }
+ }
+
if (data.authType === "none") {
return;
}
@@ -458,25 +489,27 @@ export function HostManagerEditor({
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: t("hosts.passwordRequired"),
+ message: t("hosts.passwordRequired", "Password is required"),
path: ["password"],
});
}
} else if (data.authType === "key") {
if (
!data.key ||
- (typeof data.key === "string" && data.key.trim() === "")
+ (typeof data.key === "string" &&
+ data.key.trim() === "" &&
+ data.key !== "existing_key")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: t("hosts.sshKeyRequired"),
+ message: t("hosts.sshKeyRequired", "SSH key is required"),
path: ["key"],
});
}
if (!data.keyType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: t("hosts.keyTypeRequired"),
+ message: t("hosts.keyTypeRequired", "Key type is required"),
path: ["keyType"],
});
}
@@ -484,7 +517,7 @@ export function HostManagerEditor({
if (!data.credentialId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: t("hosts.credentialRequired"),
+ message: t("hosts.credentialRequired", "Credential is required"),
path: ["credentialId"],
});
}
@@ -510,6 +543,7 @@ export function HostManagerEditor({
resolver: zodResolver(formSchema) as any,
mode: "all",
defaultValues: {
+ connectionType: "ssh" as const,
name: "",
ip: "",
port: 22,
@@ -547,32 +581,80 @@ export function HostManagerEditor({
socks5Password: "",
socks5ProxyChain: [],
enableDocker: false,
+ domain: "",
+ security: "any",
+ ignoreCert: true,
+ guacamoleConfig: {},
},
});
const watchedFields = form.watch();
const formState = form.formState;
+ const watchedConnectionType = form.watch("connectionType") || "ssh";
+
+ const prevConnectionTypeRef = useRef("ssh");
+ useEffect(() => {
+ const prev = prevConnectionTypeRef.current;
+ const current = watchedConnectionType;
+ if (prev === current) return;
+ prevConnectionTypeRef.current = current;
+
+ const portDefaults: Record = {
+ ssh: 22,
+ rdp: 3389,
+ vnc: 5900,
+ telnet: 23,
+ };
+ const currentPort = form.getValues("port");
+ const oldDefault = portDefaults[prev] || 22;
+ if (currentPort === oldDefault) {
+ form.setValue("port", portDefaults[current] || 22);
+ }
+
+ if (current !== "ssh") {
+ const currentStatsConfig = form.getValues("statsConfig");
+ form.setValue("statsConfig", {
+ ...currentStatsConfig,
+ metricsEnabled: false,
+ disableTcpPing: false,
+ });
+ }
+
+ if (activeTab !== "general" && current !== "ssh") {
+ setActiveTab("general");
+ }
+ }, [watchedConnectionType]);
const isFormValid = React.useMemo(() => {
- const values = form.getValues();
+ const errors = formState.errors;
- if (!values.ip || !values.username || values.username.trim() === "")
+ if (!watchedFields.ip) return false;
+
+ if (watchedFields.connectionType !== "ssh") {
+ const port = Number(watchedFields.port);
+ return !errors.ip && port >= 1 && port <= 65535;
+ }
+
+ if (!watchedFields.username || watchedFields.username.trim() === "")
return false;
if (authTab === "password") {
- return !!(values.password && values.password.trim() !== "");
+ if (!watchedFields.password || watchedFields.password.trim() === "")
+ return false;
} else if (authTab === "key") {
- return !!(values.key && values.keyType);
+ if (!watchedFields.key || !watchedFields.keyType) return false;
} else if (authTab === "credential") {
- return !!values.credentialId;
+ if (!watchedFields.credentialId) return false;
} else if (authTab === "none") {
- return true;
+ // No auth required
} else if (authTab === "opkssh") {
- return true;
+ // No auth required
+ } else {
+ return false;
}
- return false;
- }, [watchedFields, authTab]);
+ return Object.keys(errors).length === 0;
+ }, [watchedFields, authTab, formState.errors]);
useEffect(() => {
const updateAuthFields = async () => {
@@ -605,7 +687,10 @@ export function HostManagerEditor({
}
}
} else if (authTab === "none") {
- form.setValue("password", "", { shouldValidate: true });
+ const connectionType = form.getValues("connectionType");
+ if (connectionType === "ssh") {
+ form.setValue("password", "", { shouldValidate: true });
+ }
form.setValue("key", null, { shouldValidate: true });
form.setValue("keyPassword", "", { shouldValidate: true });
form.setValue("keyType", "auto", { shouldValidate: true });
@@ -628,14 +713,16 @@ export function HostManagerEditor({
useEffect(() => {
if (editingHost) {
const cleanedHost = { ...editingHost };
- if (cleanedHost.credentialId && cleanedHost.key) {
- cleanedHost.key = undefined;
- cleanedHost.keyPassword = undefined;
- cleanedHost.keyType = undefined;
- } else if (cleanedHost.credentialId && cleanedHost.password) {
- cleanedHost.password = undefined;
- } else if (cleanedHost.key && cleanedHost.password) {
- cleanedHost.password = undefined;
+ if ((cleanedHost as any).connectionType === "ssh") {
+ if (cleanedHost.credentialId && cleanedHost.key) {
+ cleanedHost.key = undefined;
+ cleanedHost.keyPassword = undefined;
+ cleanedHost.keyType = undefined;
+ } else if (cleanedHost.credentialId && cleanedHost.password) {
+ cleanedHost.password = undefined;
+ } else if (cleanedHost.key && cleanedHost.password) {
+ cleanedHost.password = undefined;
+ }
}
const defaultAuthType = (cleanedHost.authType ||
@@ -669,6 +756,7 @@ export function HostManagerEditor({
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
const formData: Partial = {
+ connectionType: (cleanedHost as any).connectionType || "ssh",
name: cleanedHost.name || "",
ip: cleanedHost.ip || "",
port: cleanedHost.port || 22,
@@ -734,6 +822,21 @@ export function HostManagerEditor({
? cleanedHost.socks5ProxyChain
: [],
enableDocker: Boolean(cleanedHost.enableDocker),
+ domain: (cleanedHost as any).domain || "",
+ security: (cleanedHost as any).security || "any",
+ ignoreCert: (cleanedHost as any).ignoreCert ?? true,
+ guacamoleConfig: (() => {
+ const cfg = (cleanedHost as any).guacamoleConfig;
+ if (!cfg) return {};
+ if (typeof cfg === "string") {
+ try {
+ return JSON.parse(cfg);
+ } catch {
+ return {};
+ }
+ }
+ return cfg;
+ })(),
showTerminalInSidebar: cleanedHost.showTerminalInSidebar ?? true,
showFileManagerInSidebar: cleanedHost.showFileManagerInSidebar ?? false,
showTunnelInSidebar: cleanedHost.showTunnelInSidebar ?? false,
@@ -750,7 +853,11 @@ export function HostManagerEditor({
setProxyMode("single");
}
- if (defaultAuthType === "password") {
+ if (cleanedHost.connectionType !== "ssh") {
+ if (cleanedHost.password) {
+ formData.password = cleanedHost.password;
+ }
+ } else if (defaultAuthType === "password") {
formData.password = cleanedHost.password || "";
} else if (defaultAuthType === "key") {
formData.key = editingHost.id ? "existing_key" : editingHost.key;
@@ -774,6 +881,7 @@ export function HostManagerEditor({
} else {
setAuthTab("password");
const defaultFormData: Partial = {
+ connectionType: "ssh" as const,
name: "",
ip: "",
port: 22,
@@ -799,6 +907,10 @@ export function HostManagerEditor({
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
enableDocker: false,
+ domain: "",
+ security: "any",
+ ignoreCert: true,
+ guacamoleConfig: {},
showTerminalInSidebar: true,
showFileManagerInSidebar: false,
showTunnelInSidebar: false,
@@ -834,23 +946,46 @@ export function HostManagerEditor({
...data,
};
- if (
- data.terminalConfig?.sudoPasswordAutoFill &&
- data.terminalConfig?.sudoPassword
- ) {
- submitData.sudoPassword = data.terminalConfig.sudoPassword;
- }
+ (submitData as any).connectionType = data.connectionType;
+ (submitData as any).domain = data.domain;
+ (submitData as any).security = data.security;
+ (submitData as any).ignoreCert = data.ignoreCert;
+ (submitData as any).guacamoleConfig = data.guacamoleConfig;
- if (data.authType !== "credential") {
- submitData.credentialId = undefined;
- }
- if (data.authType !== "password") {
- submitData.password = undefined;
- }
- if (data.authType !== "key") {
+ if (data.connectionType !== "ssh") {
+ submitData.authType = "none";
submitData.key = undefined;
submitData.keyPassword = undefined;
submitData.keyType = undefined;
+ submitData.credentialId = undefined;
+ submitData.tunnelConnections = [];
+ submitData.jumpHosts = [];
+ (submitData as any).useSocks5 = false;
+ (submitData as any).socks5ProxyChain = [];
+ submitData.forceKeyboardInteractive = false;
+ submitData.enableTunnel = false;
+ submitData.enableFileManager = false;
+ submitData.enableDocker = false;
+ submitData.enableTerminal = true;
+ } else {
+ if (
+ data.terminalConfig?.sudoPasswordAutoFill &&
+ data.terminalConfig?.sudoPassword
+ ) {
+ submitData.sudoPassword = data.terminalConfig.sudoPassword;
+ }
+
+ if (data.authType !== "credential") {
+ submitData.credentialId = undefined;
+ }
+ if (data.authType !== "password") {
+ submitData.password = undefined;
+ }
+ if (data.authType !== "key") {
+ submitData.key = undefined;
+ submitData.keyPassword = undefined;
+ submitData.keyType = undefined;
+ }
}
if (data.authType === "key") {
@@ -955,6 +1090,11 @@ export function HostManagerEditor({
enableTerminal: "terminal",
terminalConfig: "terminal",
enableDocker: "docker",
+ domain: "general",
+ security: "general",
+ ignoreCert: "general",
+ guacamoleConfig: "remote_desktop",
+ connectionType: "general",
enableTunnel: "tunnel",
tunnelConnections: "tunnel",
enableFileManager: "file_manager",
@@ -1194,6 +1334,50 @@ export function HostManagerEditor({
: t("hosts.addHost")}
+ {guacEnabled && (
+ (
+
+
+
+
+
+ {t("hosts.ssh")}
+
+
+ {t("hosts.rdp")}
+
+
+ {t("hosts.vnc")}
+
+
+ {t("hosts.telnet")}
+
+
+
+
+
+ )}
+ />
+ )}
{t("hosts.general")}
-
- {t("hosts.terminal")}
-
-
- Docker
-
-
- {t("hosts.tunnel")}
-
-
- {t("hosts.fileManager")}
-
+ {watchedConnectionType === "ssh" && (
+ <>
+
+ {t("hosts.terminal")}
+
+
+ Docker
+
+
+ {t("hosts.tunnel")}
+
+
+ {t("hosts.fileManager")}
+
+ >
+ )}
- {t("hosts.statistics")}
+ {watchedConnectionType === "ssh"
+ ? t("hosts.statistics")
+ : t("hosts.status")}
- {!editingHost?.isShared && (
-
- {t("rbac.sharing")}
+ {watchedConnectionType !== "ssh" && (
+
+ {t("hosts.remoteDesktop")}
)}
+ {watchedConnectionType === "ssh" &&
+ !editingHost?.isShared && (
+
+ {t("rbac.sharing")}
+
+ )}
-
-
-
-
-
-
-
-
-
-
-
-
+ {watchedConnectionType === "ssh" && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
-
-
-
-
+ {watchedConnectionType === "ssh" ? (
+
+ ) : (
+
+ )}
+ {watchedConnectionType !== "ssh" && (
+
+
+
+ )}
+ {watchedConnectionType === "ssh" && (
+
+
+
+ )}
diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx
index ee24cf98..f5eccb73 100644
--- a/src/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx
+++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx
@@ -41,6 +41,9 @@ import {
refreshServerPolling,
isElectron,
getConfiguredServerUrl,
+ getGuacamoleTokenFromHost,
+ getGuacamoleToken,
+ logActivity,
} from "@/ui/main-axios.ts";
import { useServerStatus } from "@/ui/contexts/ServerStatusContext";
import { toast } from "sonner";
@@ -83,6 +86,9 @@ import {
Plus,
ListChecks,
ChevronDown,
+ Monitor,
+ MessagesSquare,
+ Eye,
} from "lucide-react";
import type {
SSHHost,
@@ -587,6 +593,7 @@ export function HostManagerViewer({
const getSampleData = () => ({
hosts: [
{
+ connectionType: "ssh",
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
@@ -607,6 +614,7 @@ export function HostManagerViewer({
defaultPath: "/var/www",
},
{
+ connectionType: "ssh",
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
@@ -645,6 +653,7 @@ export function HostManagerViewer({
},
},
{
+ connectionType: "ssh",
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
@@ -667,6 +676,66 @@ export function HostManagerViewer({
sudoPassword: "dev_sudo_password",
},
{
+ connectionType: "rdp",
+ name: "Windows Server 2022",
+ ip: "192.168.1.200",
+ port: 3389,
+ username: "Administrator",
+ password: "windows_password",
+ domain: "COMPANY",
+ security: "nla",
+ ignoreCert: false,
+ folder: "Remote Desktop",
+ tags: ["rdp", "windows", "production"],
+ pin: false,
+ notes: "Production Windows Server with RDP access",
+ guacamoleConfig: {
+ "enable-drive": true,
+ "drive-path": "/shared",
+ "create-drive-path": true,
+ "server-layout": "en-us-qwerty",
+ "resize-method": "display-update",
+ },
+ },
+ {
+ connectionType: "vnc",
+ name: "Ubuntu Desktop",
+ ip: "192.168.1.201",
+ port: 5900,
+ username: "vncuser",
+ password: "vnc_password",
+ folder: "Remote Desktop",
+ tags: ["vnc", "linux", "desktop"],
+ pin: false,
+ notes: "Ubuntu desktop with VNC server",
+ guacamoleConfig: {
+ "color-depth": 24,
+ cursor: "remote",
+ "read-only": false,
+ "clipboard-encoding": "UTF-8",
+ },
+ },
+ {
+ connectionType: "telnet",
+ name: "Network Switch",
+ ip: "192.168.1.254",
+ port: 23,
+ username: "admin",
+ password: "switch_password",
+ folder: "Infrastructure",
+ tags: ["telnet", "network", "switch"],
+ pin: false,
+ notes: "Legacy network switch with Telnet access",
+ guacamoleConfig: {
+ "color-scheme": "green-black",
+ "font-name": "monospace",
+ "font-size": 12,
+ scrollback: 1024,
+ backspace: 127,
+ },
+ },
+ {
+ connectionType: "ssh",
name: "Jump Host Server",
ip: "10.0.0.50",
port: 22,
@@ -693,6 +762,7 @@ export function HostManagerViewer({
],
},
{
+ connectionType: "ssh",
name: "Server with SOCKS5 Proxy",
ip: "10.10.10.100",
port: 22,
@@ -713,6 +783,7 @@ export function HostManagerViewer({
socks5Password: "proxypass",
},
{
+ connectionType: "ssh",
name: "Customized Terminal Server",
ip: "192.168.1.150",
port: 22,
@@ -1388,34 +1459,47 @@ export function HostManagerViewer({
)}
{(() => {
const statsConfig = (() => {
+ if (!host.statsConfig) {
+ return DEFAULT_STATS_CONFIG;
+ }
+ if (
+ typeof host.statsConfig === "object"
+ ) {
+ return host.statsConfig;
+ }
try {
- return host.statsConfig
- ? JSON.parse(host.statsConfig)
- : DEFAULT_STATS_CONFIG;
- } catch {
+ return JSON.parse(host.statsConfig);
+ } catch (e) {
return DEFAULT_STATS_CONFIG;
}
})();
- const shouldShowStatus =
- statsConfig.statusCheckEnabled !==
- false;
- const serverStatus = getStatus(host.id);
+ const shouldShowStatus = ![
+ false,
+ "false",
+ ].includes(
+ statsConfig.statusCheckEnabled,
+ );
- return shouldShowStatus ? (
+ if (!shouldShowStatus) return null;
+
+ const serverStatus = getStatus(host.id);
+ return (
- ) : null;
+ );
})()}
{host.pin && (
)}
{host.name ||
- `${host.username}@${host.ip}`}
+ (host.username
+ ? `${host.username}@${host.ip}`
+ : host.ip)}
{(host as any).isShared && (
{host.ip}:{host.port}
-
- {host.username}
-
+ {host.username && (
+
+ {host.username}
+
+ )}
ID: {host.id}
@@ -1569,74 +1655,118 @@ export function HostManagerViewer({
- {host.enableTerminal && (
- {
- e.stopPropagation();
- copyFullScreenUrl(
- host,
- "terminal",
- );
- }}
- >
-
- {t("hosts.copyTerminalUrl")}
-
- )}
- {host.enableFileManager && (
- {
- e.stopPropagation();
- copyFullScreenUrl(
- host,
- "file-manager",
- );
- }}
- >
-
- {t("hosts.copyFileManagerUrl")}
-
- )}
- {host.enableTunnel && (
- {
- e.stopPropagation();
- copyFullScreenUrl(
- host,
- "tunnel",
- );
- }}
- >
-
- {t("hosts.copyTunnelUrl")}
-
- )}
- {
- e.stopPropagation();
- copyFullScreenUrl(
- host,
- "server-stats",
+ {(() => {
+ const connType = (host as any)
+ .connectionType;
+ const isRemoteDesktop =
+ connType === "rdp" ||
+ connType === "vnc" ||
+ connType === "telnet";
+
+ if (isRemoteDesktop) {
+ return (
+ {
+ e.stopPropagation();
+ copyFullScreenUrl(
+ host,
+ connType,
+ );
+ }}
+ >
+ {connType === "rdp" ? (
+
+ ) : connType === "vnc" ? (
+
+ ) : (
+
+ )}
+ {t(
+ "hosts.copyRemoteDesktopUrl",
+ )}
+
);
- }}
- >
-
- {t("hosts.copyServerStatsUrl")}
-
- {host.enableDocker && (
- {
- e.stopPropagation();
- copyFullScreenUrl(
- host,
- "docker",
- );
- }}
- >
-
- {t("hosts.copyDockerUrl")}
-
- )}
+ }
+
+ return (
+ <>
+ {host.enableTerminal && (
+ {
+ e.stopPropagation();
+ copyFullScreenUrl(
+ host,
+ "terminal",
+ );
+ }}
+ >
+
+ {t(
+ "hosts.copyTerminalUrl",
+ )}
+
+ )}
+ {host.enableFileManager && (
+ {
+ e.stopPropagation();
+ copyFullScreenUrl(
+ host,
+ "file-manager",
+ );
+ }}
+ >
+
+ {t(
+ "hosts.copyFileManagerUrl",
+ )}
+
+ )}
+ {host.enableTunnel && (
+ {
+ e.stopPropagation();
+ copyFullScreenUrl(
+ host,
+ "tunnel",
+ );
+ }}
+ >
+
+ {t("hosts.copyTunnelUrl")}
+
+ )}
+ {
+ e.stopPropagation();
+ copyFullScreenUrl(
+ host,
+ "server-stats",
+ );
+ }}
+ >
+
+ {t(
+ "hosts.copyServerStatsUrl",
+ )}
+
+ {host.enableDocker && (
+ {
+ e.stopPropagation();
+ copyFullScreenUrl(
+ host,
+ "docker",
+ );
+ }}
+ >
+
+ {t("hosts.copyDockerUrl")}
+
+ )}
+ >
+ );
+ })()}
>
@@ -1671,185 +1801,339 @@ export function HostManagerViewer({
)}
- {host.enableTerminal && (
-
-
- {t("hosts.terminalBadge")}
-
- )}
- {host.enableTunnel && (
-
-
- {t("hosts.tunnelBadge")}
- {host.tunnelConnections &&
- host.tunnelConnections.length > 0 && (
-
- ({host.tunnelConnections.length})
-
+ {(() => {
+ const connType = (host as any)
+ .connectionType;
+ if (connType === "rdp") {
+ return (
+
+
+ {t("hosts.rdp")}
+
+ );
+ }
+ if (connType === "vnc") {
+ return (
+
+
+ {t("hosts.vnc")}
+
+ );
+ }
+ if (connType === "telnet") {
+ return (
+
+
+ {t("hosts.telnet")}
+
+ );
+ }
+ return (
+ <>
+ {host.enableTerminal && (
+
+
+ {t("hosts.terminalBadge")}
+
)}
-
- )}
- {host.enableFileManager && (
-
-
- {t("hosts.fileManagerBadge")}
-
- )}
- {host.enableDocker && (
-
-
- {t("hosts.docker")}
-
- )}
+ {host.enableTunnel && (
+
+
+ {t("hosts.tunnelBadge")}
+ {host.tunnelConnections &&
+ host.tunnelConnections.length >
+ 0 && (
+
+ (
+ {
+ host.tunnelConnections
+ .length
+ }
+ )
+
+ )}
+
+ )}
+ {host.enableFileManager && (
+
+
+ {t("hosts.fileManagerBadge")}
+
+ )}
+ {host.enableDocker && (
+
+
+ {t("hosts.docker")}
+
+ )}
+ >
+ );
+ })()}
- {host.enableTerminal && (
-
-
- {
- e.stopPropagation();
- const title = host.name?.trim()
- ? host.name
- : `${host.username}@${host.ip}:${host.port}`;
- addTab({
- type: "terminal",
- title,
- hostConfig: host,
- });
- }}
- className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
- >
-
-
-
-
- {t("hosts.openTerminal")}
-
-
- )}
- {host.enableFileManager && (
-
-
- {
- e.stopPropagation();
- const title = host.name?.trim()
- ? host.name
- : `${host.username}@${host.ip}:${host.port}`;
- addTab({
- type: "file_manager",
- title,
- hostConfig: host,
- });
- }}
- className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
- >
-
-
-
-
- Open File Manager
-
-
- )}
- {host.enableTunnel && (
-
-
- {
- e.stopPropagation();
- const title = host.name?.trim()
- ? host.name
- : `${host.username}@${host.ip}:${host.port}`;
- addTab({
- type: "tunnel",
- title,
- hostConfig: host,
- });
- }}
- className="h-7 px-2 hover:bg-orange-500/10 hover:border-orange-500/50 flex-1"
- >
-
-
-
-
- Open Tunnels
-
-
- )}
- {host.enableDocker && (
-
-
- {
- e.stopPropagation();
- const title = host.name?.trim()
- ? host.name
- : `${host.username}@${host.ip}:${host.port}`;
- addTab({
- type: "docker",
- title,
- hostConfig: host,
- });
- }}
- className="h-7 px-2 hover:bg-cyan-500/10 hover:border-cyan-500/50 flex-1"
- >
-
-
-
-
- {t("hosts.openDocker")}
-
-
- )}
-
-
- {
- e.stopPropagation();
- const title = host.name?.trim()
- ? host.name
- : `${host.username}@${host.ip}:${host.port}`;
- addTab({
- type: "server_stats",
- title,
- hostConfig: host,
- });
- }}
- className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
- >
-
-
-
-
- Open Server Details
-
-
+ {(() => {
+ const connType = (host as any)
+ .connectionType;
+ const isRemoteDesktop =
+ connType === "rdp" ||
+ connType === "vnc" ||
+ connType === "telnet";
+
+ if (isRemoteDesktop) {
+ return (
+
+
+ {
+ e.stopPropagation();
+ const title = host.name?.trim()
+ ? host.name
+ : `${host.ip}:${host.port}`;
+
+ try {
+ const protocol = connType as
+ | "rdp"
+ | "vnc"
+ | "telnet";
+ const result =
+ await getGuacamoleToken({
+ protocol,
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ ignoreCert:
+ host.ignoreCert,
+ guacamoleConfig:
+ host.guacamoleConfig as any,
+ });
+
+ addTab({
+ type: protocol,
+ title,
+ hostConfig: host,
+ connectionConfig: {
+ token: result.token,
+ protocol,
+ type: protocol,
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ "ignore-cert":
+ host.ignoreCert,
+ },
+ });
+
+ try {
+ await logActivity(
+ protocol,
+ host.id,
+ title,
+ );
+ } catch (err) {
+ console.warn(
+ `Failed to log ${protocol} activity:`,
+ err,
+ );
+ }
+ } catch (error) {
+ toast.error(
+ `Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
+ );
+ }
+ }}
+ className="h-7 px-2 hover:bg-indigo-500/10 hover:border-indigo-500/50 flex-1"
+ >
+ {connType === "rdp" ? (
+
+ ) : connType === "vnc" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {t("hosts.remoteDesktop")}
+
+
+ );
+ }
+
+ return (
+ <>
+ {host.enableTerminal && (
+
+
+ {
+ e.stopPropagation();
+ const title =
+ host.name?.trim()
+ ? host.name
+ : `${host.username}@${host.ip}:${host.port}`;
+ addTab({
+ type: "terminal",
+ title,
+ hostConfig: host,
+ });
+ }}
+ className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
+ >
+
+
+
+
+ {t("hosts.openTerminal")}
+
+
+ )}
+ {host.enableFileManager && (
+
+
+ {
+ e.stopPropagation();
+ const title =
+ host.name?.trim()
+ ? host.name
+ : `${host.username}@${host.ip}:${host.port}`;
+ addTab({
+ type: "file_manager",
+ title,
+ hostConfig: host,
+ });
+ }}
+ className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
+ >
+
+
+
+
+
+ {t("hosts.openFileManager")}
+
+
+
+ )}
+ {host.enableTunnel && (
+
+
+ {
+ e.stopPropagation();
+ const title =
+ host.name?.trim()
+ ? host.name
+ : `${host.username}@${host.ip}:${host.port}`;
+ addTab({
+ type: "tunnel",
+ title,
+ hostConfig: host,
+ });
+ }}
+ className="h-7 px-2 hover:bg-orange-500/10 hover:border-orange-500/50 flex-1"
+ >
+
+
+
+
+ {t("hosts.openTunnels")}
+
+
+ )}
+ {host.enableDocker && (
+
+
+ {
+ e.stopPropagation();
+ const title =
+ host.name?.trim()
+ ? host.name
+ : `${host.username}@${host.ip}:${host.port}`;
+ addTab({
+ type: "docker",
+ title,
+ hostConfig: host,
+ });
+ }}
+ className="h-7 px-2 hover:bg-cyan-500/10 hover:border-cyan-500/50 flex-1"
+ >
+
+
+
+
+ {t("hosts.openDocker")}
+
+
+ )}
+
+
+ {
+ e.stopPropagation();
+ const title = host.name?.trim()
+ ? host.name
+ : `${host.username}@${host.ip}:${host.port}`;
+ addTab({
+ type: "server_stats",
+ title,
+ hostConfig: host,
+ });
+ }}
+ className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
+ >
+
+
+
+
+ {t("hosts.openServerStats")}
+
+
+ >
+ );
+ })()}
diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx
index 81950043..01e6017a 100644
--- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx
+++ b/src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx
@@ -52,6 +52,7 @@ import { toast } from "sonner";
export function HostGeneralTab({
form,
+ connectionType,
authTab,
setAuthTab,
keyInputMethod,
@@ -213,7 +214,11 @@ export function HostGeneralTab({
{t("hosts.port")}
-
+
)}
@@ -430,62 +435,1019 @@ export function HostGeneralTab({
)}
/>
-
- {t("hosts.authentication")}
-
- {
- if (editingHost?.isShared) return;
- const newAuthType = value as
- | "password"
- | "key"
- | "credential"
- | "none"
- | "opkssh";
- setAuthTab(newAuthType);
- form.setValue("authType", newAuthType);
- }}
- className="flex-1 flex flex-col h-full min-h-0"
- >
-
-
+
+ {t("hosts.authentication")}
+
+ {
+ if (editingHost?.isShared) return;
+ const newAuthType = value as
+ | "password"
+ | "key"
+ | "credential"
+ | "none"
+ | "opkssh";
+ setAuthTab(newAuthType);
+ form.setValue("authType", newAuthType);
+ }}
+ className="flex-1 flex flex-col h-full min-h-0"
>
- {t("hosts.password")}
-
-
- {t("hosts.key")}
-
-
- {t("hosts.credential")}
-
-
- {t("hosts.none")}
-
-
- {t("hosts.opkssh")}
-
-
-
+
+
+ {t("hosts.password")}
+
+
+ {t("hosts.key")}
+
+
+ {t("hosts.credential")}
+
+
+ {t("hosts.none")}
+
+
+ {t("hosts.opkssh")}
+
+
+
+ (
+
+ {t("hosts.password")}
+
+
+
+
+ )}
+ />
+
+
+ {
+ setKeyInputMethod(value as "upload" | "paste");
+ if (value === "upload") {
+ form.setValue("key", null);
+ } else {
+ form.setValue("key", "");
+ }
+ }}
+ className="w-full"
+ >
+
+
+ {t("hosts.uploadFile")}
+
+ {t("hosts.pasteKey")}
+
+
+ (
+
+ {t("hosts.sshPrivateKey")}
+
+
+ {
+ const file = e.target.files?.[0];
+ field.onChange(file || null);
+ }}
+ className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
+ />
+
+
+ {field.value === "existing_key"
+ ? t("hosts.existingKey")
+ : field.value
+ ? editingHost
+ ? t("hosts.updateKey")
+ : (field.value as File).name
+ : t("hosts.upload")}
+
+
+
+
+
+ )}
+ />
+
+
+ (
+
+ {t("hosts.sshPrivateKey")}
+
+ field.onChange(value)}
+ placeholder={t("placeholders.pastePrivateKey")}
+ theme={editorTheme}
+ className="border border-input rounded-md overflow-hidden"
+ minHeight="120px"
+ basicSetup={{
+ lineNumbers: true,
+ foldGutter: false,
+ dropCursor: false,
+ allowMultipleSelections: false,
+ highlightSelectionMatches: false,
+ }}
+ extensions={[
+ EditorView.theme({
+ ".cm-scroller": {
+ overflow: "auto",
+ scrollbarWidth: "thin",
+ scrollbarColor:
+ "var(--scrollbar-thumb) var(--scrollbar-track)",
+ },
+ }),
+ ]}
+ />
+
+
+ )}
+ />
+
+
+
+
(
+
+ {t("hosts.keyPassword")}
+
+
+
+
+ )}
+ />
+ (
+
+ {t("hosts.keyType")}
+
+
+
+ setKeyTypeDropdownOpen((open) => !open)
+ }
+ >
+ {keyTypeOptions.find(
+ (opt) => opt.value === field.value,
+ )?.label || t("hosts.autoDetect")}
+
+ {keyTypeDropdownOpen && (
+
+
+ {keyTypeOptions.map((opt) => (
+ {
+ field.onChange(opt.value);
+ setKeyTypeDropdownOpen(false);
+ }}
+ >
+ {opt.label}
+
+ ))}
+
+
+ )}
+
+
+
+ )}
+ />
+
+
+
+
+
(
+
+ {editingHost?.isShared ? (
+
+ {t("hosts.cannotChangeAuthAsSharedUser")}
+
+ ) : (
+ {
+ if (
+ credential &&
+ credential.username &&
+ !form.getValues("overrideCredentialUsername")
+ ) {
+ form.setValue("username", credential.username);
+ }
+ }}
+ />
+ )}
+ {!editingHost?.isShared && (
+
+ {t("hosts.credentialDescription")}
+
+ )}
+
+ )}
+ />
+ {form.watch("credentialId") &&
+ (() => {
+ const selectedCredential = credentials.find(
+ (c) => c.id === form.watch("credentialId"),
+ );
+ return selectedCredential?.username ? (
+ (
+
+
+
+ {t("hosts.overrideCredentialUsername")}
+
+
+ {t("hosts.overrideCredentialUsernameDesc")}
+
+
+
+
+
+
+ )}
+ />
+ ) : null;
+ })()}
+
+
+
+
+
+ {t("hosts.noneAuthTitle")}
+ {t("hosts.noneAuthDescription")}
+
+ {t("hosts.noneAuthDetails")}
+
+
+
+
+
+
+
+ {t("hosts.opksshAuthTitle")}
+ {t("hosts.opksshAuthDescription")}
+
+ window.open("https://docs.termix.site/opkssh", "_blank")
+ }
+ >
+ {t("common.documentation")}
+
+
+
+
+
+
+
+
+
+ {t("hosts.sidebarCustomization")}
+
+
+
+
+ {t("hosts.sidebarCustomizationDesc")}
+
+
+
+ {form.watch("enableTerminal") && (
+ (
+
+
+
+ {t("hosts.showTerminalInSidebar")}
+
+
+ {t("hosts.showTerminalInSidebarDesc")}
+
+
+
+
+
+
+ )}
+ />
+ )}
+
+ {form.watch("enableFileManager") && (
+ (
+
+
+
+ {t("hosts.showFileManagerInSidebar")}
+
+
+ {t("hosts.showFileManagerInSidebarDesc")}
+
+
+
+
+
+
+ )}
+ />
+ )}
+
+ {form.watch("enableTunnel") && (
+ (
+
+
+
+ {t("hosts.showTunnelInSidebar")}
+
+
+ {t("hosts.showTunnelInSidebarDesc")}
+
+
+
+
+
+
+ )}
+ />
+ )}
+
+ {form.watch("enableDocker") && (
+ (
+
+
+
+ {t("hosts.showDockerInSidebar")}
+
+
+ {t("hosts.showDockerInSidebarDesc")}
+
+
+
+
+
+
+ )}
+ />
+ )}
+
+ (
+
+
+
+ {t("hosts.showServerStatsInSidebar")}
+
+
+ {t("hosts.showServerStatsInSidebarDesc")}
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+
+ {t("hosts.advancedAuthSettings")}
+
+
+ (
+
+
+
+ {t("hosts.forceKeyboardInteractive")}
+
+
+ {t("hosts.forceKeyboardInteractiveDesc")}
+
+
+
+
+
+
+ )}
+ />
+
+
+
+
+ {t("hosts.jumpHosts")}
+
+
+
+ {t("hosts.jumpHostsDescription")}
+
+
+ (
+
+ {t("hosts.jumpHostChain")}
+
+
+ {field.value.map((jumpHost, index) => (
+
{
+ const newJumpHosts = [...field.value];
+ newJumpHosts[index] = { hostId };
+ field.onChange(newJumpHosts);
+ }}
+ onRemove={() => {
+ const newJumpHosts = field.value.filter(
+ (_, i) => i !== index,
+ );
+ field.onChange(newJumpHosts);
+ }}
+ t={t}
+ />
+ ))}
+ {
+ field.onChange([...field.value, { hostId: 0 }]);
+ }}
+ >
+
+ {t("hosts.addJumpHost")}
+
+
+
+
+ {t("hosts.jumpHostsOrder")}
+
+
+ )}
+ />
+
+
+
+
+ {t("hosts.socks5Proxy")}
+
+
+
+ {t("hosts.socks5Description")}
+
+
+
+ (
+
+
+ {t("hosts.enableSocks5")}
+
+ {t("hosts.enableSocks5Description")}
+
+
+
+
+
+
+ )}
+ />
+
+ {form.watch("useSocks5") && (
+
+
+
{t("hosts.socks5ProxyMode")}
+
+ setProxyMode("single")}
+ className="flex-1"
+ >
+ {t("hosts.socks5UseSingleProxy")}
+
+ setProxyMode("chain")}
+ className="flex-1"
+ >
+ {t("hosts.socks5UseProxyChain")}
+
+
+
+
+ {proxyMode === "single" && (
+
+ )}
+
+ {proxyMode === "chain" && (
+
+
+
{t("hosts.socks5ProxyChain")}
+
{
+ const currentChain =
+ form.watch("socks5ProxyChain") || [];
+ form.setValue("socks5ProxyChain", [
+ ...currentChain,
+ {
+ host: "",
+ port: 1080,
+ type: 5 as 4 | 5 | "http",
+ username: "",
+ password: "",
+ },
+ ]);
+ }}
+ >
+
+ {t("hosts.addProxyNode")}
+
+
+
+ {(form.watch("socks5ProxyChain") || []).length ===
+ 0 && (
+
+ {t("hosts.noProxyNodes")}
+
+ )}
+
+ {(form.watch("socks5ProxyChain") || []).map(
+ (node: any, index: number) => (
+
+
+
+ {t("hosts.proxyNode")} {index + 1}
+
+ {
+ const currentChain =
+ form.watch("socks5ProxyChain") || [];
+ form.setValue(
+ "socks5ProxyChain",
+ currentChain.filter(
+ (_: any, i: number) => i !== index,
+ ),
+ );
+ }}
+ >
+
+
+
+
+
+
+ {t("hosts.socks5Host")}
+ {
+ const currentChain =
+ form.watch("socks5ProxyChain") || [];
+ const newChain = [...currentChain];
+ newChain[index] = {
+ ...newChain[index],
+ host: e.target.value,
+ };
+ form.setValue(
+ "socks5ProxyChain",
+ newChain,
+ );
+ }}
+ onBlur={(e) => {
+ const currentChain =
+ form.watch("socks5ProxyChain") || [];
+ const newChain = [...currentChain];
+ newChain[index] = {
+ ...newChain[index],
+ host: e.target.value.trim(),
+ };
+ form.setValue(
+ "socks5ProxyChain",
+ newChain,
+ );
+ }}
+ />
+
+
+
+ {t("hosts.socks5Port")}
+ {
+ const currentChain =
+ form.watch("socks5ProxyChain") || [];
+ const newChain = [...currentChain];
+ newChain[index] = {
+ ...newChain[index],
+ port: parseInt(e.target.value) || 1080,
+ };
+ form.setValue(
+ "socks5ProxyChain",
+ newChain,
+ );
+ }}
+ />
+
+
+
+
+ {t("hosts.proxyType")}
+ {
+ const currentChain =
+ form.watch("socks5ProxyChain") || [];
+ const newChain = [...currentChain];
+ newChain[index] = {
+ ...newChain[index],
+ type:
+ value === "http"
+ ? ("http" as const)
+ : (parseInt(value) as 4 | 5),
+ };
+ form.setValue("socks5ProxyChain", newChain);
+ }}
+ >
+
+
+
+
+
+ {t("hosts.socks4")}
+
+
+ {t("hosts.socks5")}
+
+
+ {t("hosts.httpConnect")}
+
+
+
+
+
+
+
+
+ {t("hosts.socks5Username")}{" "}
+ {t("hosts.optional")}
+
+ {
+ const currentChain =
+ form.watch("socks5ProxyChain") || [];
+ const newChain = [...currentChain];
+ newChain[index] = {
+ ...newChain[index],
+ username: e.target.value,
+ };
+ form.setValue(
+ "socks5ProxyChain",
+ newChain,
+ );
+ }}
+ onBlur={(e) => {
+ const currentChain =
+ form.watch("socks5ProxyChain") || [];
+ const newChain = [...currentChain];
+ newChain[index] = {
+ ...newChain[index],
+ username: e.target.value.trim(),
+ };
+ form.setValue(
+ "socks5ProxyChain",
+ newChain,
+ );
+ }}
+ />
+
+
+
+
+ {t("hosts.socks5Password")}{" "}
+ {t("hosts.optional")}
+
+
{
+ const currentChain =
+ form.watch("socks5ProxyChain") || [];
+ const newChain = [...currentChain];
+ newChain[index] = {
+ ...newChain[index],
+ password: e.target.value,
+ };
+ form.setValue(
+ "socks5ProxyChain",
+ newChain,
+ );
+ }}
+ />
+
+
+
+ ),
+ )}
+
+ )}
+
+
+
+
+ {proxyTesting ? (
+ <>
+
+ {t("hosts.testingProxy")}
+ >
+ ) : (
+ t("hosts.testProxy")
+ )}
+
+
+ {(() => {
+ const path = buildConnectionPath();
+ if (path.length <= 2) return null;
+ return (
+
+
+ {t("hosts.connectionPath")}
+
+
+ {path.map((part, i) => (
+
+ {i > 0 && (
+
+ )}
+
+ {part}
+
+
+ ))}
+
+
+ );
+ })()}
+
+ )}
+
+
+
+ >
+ )}
+
+ {/* Simple password field for RDP/VNC/Telnet */}
+ {connectionType !== "ssh" && (
+
+
+ {t("hosts.authentication")}
+
)}
/>
-
-
- {
- setKeyInputMethod(value as "upload" | "paste");
- if (value === "upload") {
- form.setValue("key", null);
- } else {
- form.setValue("key", "");
- }
- }}
- className="w-full"
- >
-
- {t("hosts.uploadFile")}
- {t("hosts.pasteKey")}
-
-
- (
-
- {t("hosts.sshPrivateKey")}
-
-
- {
- const file = e.target.files?.[0];
- field.onChange(file || null);
- }}
- className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
- />
-
-
- {field.value === "existing_key"
- ? t("hosts.existingKey")
- : field.value
- ? editingHost
- ? t("hosts.updateKey")
- : (field.value as File).name
- : t("hosts.upload")}
-
-
-
-
-
- )}
- />
-
-
- (
-
- {t("hosts.sshPrivateKey")}
-
- field.onChange(value)}
- placeholder={t("placeholders.pastePrivateKey")}
- theme={editorTheme}
- className="border border-input rounded-md overflow-hidden"
- minHeight="120px"
- basicSetup={{
- lineNumbers: true,
- foldGutter: false,
- dropCursor: false,
- allowMultipleSelections: false,
- highlightSelectionMatches: false,
- }}
- extensions={[
- EditorView.theme({
- ".cm-scroller": {
- overflow: "auto",
- scrollbarWidth: "thin",
- scrollbarColor:
- "var(--scrollbar-thumb) var(--scrollbar-track)",
- },
- }),
- ]}
- />
-
-
- )}
- />
-
-
-
+ {connectionType === "rdp" && (
(
-
- {t("hosts.keyPassword")}
-
-
-
-
- )}
- />
- (
-
- {t("hosts.keyType")}
-
-
-
setKeyTypeDropdownOpen((open) => !open)}
- >
- {keyTypeOptions.find((opt) => opt.value === field.value)
- ?.label || t("hosts.autoDetect")}
-
- {keyTypeDropdownOpen && (
-
-
- {keyTypeOptions.map((opt) => (
- {
- field.onChange(opt.value);
- setKeyTypeDropdownOpen(false);
- }}
- >
- {opt.label}
-
- ))}
-
-
- )}
-
-
-
- )}
- />
-
-
-
-
-
(
- {editingHost?.isShared ? (
-
- {t("hosts.cannotChangeAuthAsSharedUser")}
-
- ) : (
- {
- if (
- credential &&
- credential.username &&
- !form.getValues("overrideCredentialUsername")
- ) {
- form.setValue("username", credential.username);
- }
- }}
- />
- )}
- {!editingHost?.isShared && (
-
- {t("hosts.credentialDescription")}
-
- )}
-
- )}
- />
- {form.watch("credentialId") &&
- (() => {
- const selectedCredential = credentials.find(
- (c) => c.id === form.watch("credentialId"),
- );
- return selectedCredential?.username ? (
- (
-
-
-
- {t("hosts.overrideCredentialUsername")}
-
-
- {t("hosts.overrideCredentialUsernameDesc")}
-
-
-
-
-
-
- )}
- />
- ) : null;
- })()}
-
-
-
-
-
- {t("hosts.noneAuthTitle")}
- {t("hosts.noneAuthDescription")}
- {t("hosts.noneAuthDetails")}
-
-
-
-
-
-
- {t("hosts.opksshAuthTitle")}
- {t("hosts.opksshAuthDescription")}
-
- window.open("https://docs.termix.site/opkssh", "_blank")
- }
- >
- {t("common.documentation")}
-
-
-
-
-
-
-
-
- {t("hosts.sidebarCustomization")}
-
-
-
- {t("hosts.sidebarCustomizationDesc")}
-
-
-
- {form.watch("enableTerminal") && (
- (
-
-
- {t("hosts.showTerminalInSidebar")}
-
- {t("hosts.showTerminalInSidebarDesc")}
-
-
-
-
-
-
- )}
- />
- )}
-
- {form.watch("enableFileManager") && (
- (
-
-
-
- {t("hosts.showFileManagerInSidebar")}
-
-
- {t("hosts.showFileManagerInSidebarDesc")}
-
-
-
-
-
-
- )}
- />
- )}
-
- {form.watch("enableTunnel") && (
- (
-
-
- {t("hosts.showTunnelInSidebar")}
-
- {t("hosts.showTunnelInSidebarDesc")}
-
-
-
-
-
-
- )}
- />
- )}
-
- {form.watch("enableDocker") && (
- (
-
-
- {t("hosts.showDockerInSidebar")}
-
- {t("hosts.showDockerInSidebarDesc")}
-
-
-
-
-
-
- )}
- />
- )}
-
- (
-
-
- {t("hosts.showServerStatsInSidebar")}
-
- {t("hosts.showServerStatsInSidebarDesc")}
-
-
+ {t("hosts.domain")}
-
)}
/>
-
-
-
-
- {t("hosts.advancedAuthSettings")}
-
- (
-
-
- {t("hosts.forceKeyboardInteractive")}
-
- {t("hosts.forceKeyboardInteractiveDesc")}
-
-
-
-
-
-
- )}
- />
-
-
-
-
- {t("hosts.jumpHosts")}
-
-
-
- {t("hosts.jumpHostsDescription")}
-
-
- (
-
- {t("hosts.jumpHostChain")}
-
-
- {field.value.map((jumpHost, index) => (
-
{
- const newJumpHosts = [...field.value];
- newJumpHosts[index] = { hostId };
- field.onChange(newJumpHosts);
- }}
- onRemove={() => {
- const newJumpHosts = field.value.filter(
- (_, i) => i !== index,
- );
- field.onChange(newJumpHosts);
- }}
- t={t}
- />
- ))}
- {
- field.onChange([...field.value, { hostId: 0 }]);
- }}
- >
-
- {t("hosts.addJumpHost")}
-
-
-
- {t("hosts.jumpHostsOrder")}
-
- )}
- />
-
-
-
-
- {t("hosts.socks5Proxy")}
-
-
-
- {t("hosts.socks5Description")}
-
-
-
- (
-
-
- {t("hosts.enableSocks5")}
-
- {t("hosts.enableSocks5Description")}
-
-
-
-
-
-
- )}
- />
-
- {form.watch("useSocks5") && (
-
-
-
{t("hosts.socks5ProxyMode")}
-
- setProxyMode("single")}
- className="flex-1"
- >
- {t("hosts.socks5UseSingleProxy")}
-
- setProxyMode("chain")}
- className="flex-1"
- >
- {t("hosts.socks5UseProxyChain")}
-
-
-
-
- {proxyMode === "single" && (
-
- )}
-
- {proxyMode === "chain" && (
-
-
-
{t("hosts.socks5ProxyChain")}
-
{
- const currentChain =
- form.watch("socks5ProxyChain") || [];
- form.setValue("socks5ProxyChain", [
- ...currentChain,
- {
- host: "",
- port: 1080,
- type: 5 as 4 | 5 | "http",
- username: "",
- password: "",
- },
- ]);
- }}
- >
-
- {t("hosts.addProxyNode")}
-
-
-
- {(form.watch("socks5ProxyChain") || []).length === 0 && (
-
- {t("hosts.noProxyNodes")}
-
- )}
-
- {(form.watch("socks5ProxyChain") || []).map(
- (node: any, index: number) => (
-
-
-
- {t("hosts.proxyNode")} {index + 1}
-
- {
- const currentChain =
- form.watch("socks5ProxyChain") || [];
- form.setValue(
- "socks5ProxyChain",
- currentChain.filter(
- (_: any, i: number) => i !== index,
- ),
- );
- }}
- >
-
-
-
-
-
-
-
- {t("hosts.proxyType")}
- {
- const currentChain =
- form.watch("socks5ProxyChain") || [];
- const newChain = [...currentChain];
- newChain[index] = {
- ...newChain[index],
- type:
- value === "http"
- ? ("http" as const)
- : (parseInt(value) as 4 | 5),
- };
- form.setValue("socks5ProxyChain", newChain);
- }}
- >
-
-
-
-
-
- {t("hosts.socks4")}
-
-
- {t("hosts.socks5")}
-
-
- {t("hosts.httpConnect")}
-
-
-
-
-
-
-
-
- {t("hosts.socks5Username")}{" "}
- {t("hosts.optional")}
-
- {
- const currentChain =
- form.watch("socks5ProxyChain") || [];
- const newChain = [...currentChain];
- newChain[index] = {
- ...newChain[index],
- username: e.target.value,
- };
- form.setValue("socks5ProxyChain", newChain);
- }}
- onBlur={(e) => {
- const currentChain =
- form.watch("socks5ProxyChain") || [];
- const newChain = [...currentChain];
- newChain[index] = {
- ...newChain[index],
- username: e.target.value.trim(),
- };
- form.setValue("socks5ProxyChain", newChain);
- }}
- />
-
-
-
-
- {t("hosts.socks5Password")}{" "}
- {t("hosts.optional")}
-
-
{
- const currentChain =
- form.watch("socks5ProxyChain") || [];
- const newChain = [...currentChain];
- newChain[index] = {
- ...newChain[index],
- password: e.target.value,
- };
- form.setValue("socks5ProxyChain", newChain);
- }}
- />
-
-
-
- ),
- )}
-
- )}
-
-
-
-
- {proxyTesting ? (
- <>
-
- {t("hosts.testingProxy")}
- >
- ) : (
- t("hosts.testProxy")
- )}
-
-
- {(() => {
- const path = buildConnectionPath();
- if (path.length <= 2) return null;
- return (
-
-
- {t("hosts.connectionPath")}
-
-
- {path.map((part, i) => (
-
- {i > 0 && (
-
- )}
-
- {part}
-
-
- ))}
-
-
- );
- })()}
-
- )}
-
-
-
+ )}
+
+ )}
);
}
diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostRemoteDesktopTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostRemoteDesktopTab.tsx
new file mode 100644
index 00000000..2428701b
--- /dev/null
+++ b/src/ui/desktop/apps/host-manager/hosts/tabs/HostRemoteDesktopTab.tsx
@@ -0,0 +1,797 @@
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+} from "@/components/ui/form.tsx";
+import { Input } from "@/components/ui/input.tsx";
+import { Switch } from "@/components/ui/switch.tsx";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select.tsx";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion.tsx";
+import { PasswordInput } from "@/components/ui/password-input.tsx";
+import type { HostRemoteDesktopTabProps } from "./shared/tab-types";
+
+function GuacField({
+ form,
+ path,
+ label,
+ description,
+ type = "text",
+ t,
+}: {
+ form: any;
+ path: string;
+ label: string;
+ description?: string;
+ type?: "text" | "number" | "password" | "switch";
+ t: (key: string) => string;
+}) {
+ const fieldName = `guacamoleConfig.${path}` as any;
+
+ if (type === "switch") {
+ return (
+ (
+
+
+ {label}
+ {description && {description} }
+
+
+
+
+
+ )}
+ />
+ );
+ }
+
+ if (type === "password") {
+ return (
+ (
+
+ {label}
+
+
+
+ {description && {description} }
+
+ )}
+ />
+ );
+ }
+
+ return (
+ (
+
+ {label}
+
+
+ field.onChange(
+ type === "number"
+ ? e.target.value === ""
+ ? undefined
+ : parseInt(e.target.value)
+ : e.target.value,
+ )
+ }
+ />
+
+ {description && {description} }
+
+ )}
+ />
+ );
+}
+
+function GuacSelect({
+ form,
+ path,
+ label,
+ options,
+ placeholder,
+}: {
+ form: any;
+ path: string;
+ label: string;
+ options: { value: string; label: string }[];
+ placeholder?: string;
+}) {
+ const fieldName = `guacamoleConfig.${path}` as any;
+
+ return (
+ (
+
+ {label}
+ field.onChange(v === "auto" ? "" : v)}
+ >
+
+
+
+
+
+
+ {options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+
+ )}
+ />
+ );
+}
+
+export function HostRemoteDesktopTab({
+ form,
+ connectionType,
+ t,
+}: HostRemoteDesktopTabProps) {
+ const isRDP = connectionType === "rdp";
+ const isVNC = connectionType === "vnc";
+ const isTelnet = connectionType === "telnet";
+
+ return (
+
+
+ {/* Connection Settings */}
+ {isRDP && (
+
+ {t("hosts.connectionSettings")}
+
+ (
+
+ {t("hosts.securityMode")}
+
+
+
+
+
+
+
+ Any
+ NLA
+ NLA Extended
+ TLS
+ VMConnect
+ RDP
+
+
+
+ )}
+ />
+ (
+
+
+ {t("hosts.ignoreCert")}
+
+ {t("hosts.ignoreCertDesc")}
+
+
+
+
+
+
+ )}
+ />
+
+
+ )}
+
+ {/* Display Settings */}
+
+ {t("hosts.displaySettings")}
+
+ {!isTelnet && (
+
+ )}
+
+
+
+
+ {!isTelnet && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ {/* Telnet Terminal Settings */}
+ {isTelnet && (
+
+
+ {t("hosts.telnetTerminalSettings")}
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Audio Settings */}
+ {(isRDP || isVNC) && (
+
+ {t("hosts.audioSettings")}
+
+
+ {isRDP && (
+
+ )}
+
+
+ )}
+
+ {/* RDP Performance */}
+ {isRDP && (
+
+ {t("hosts.rdpPerformance")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* RDP Device Redirection */}
+ {isRDP && (
+
+ {t("hosts.deviceRedirection")}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* RDP Session */}
+ {isRDP && (
+
+ {t("hosts.rdpSession")}
+
+
+
+
+
+
+
+
+ )}
+
+ {/* RDP Gateway */}
+ {isRDP && (
+
+ {t("hosts.gatewaySettings")}
+
+
+
+
+
+
+
+
+ )}
+
+ {/* RDP RemoteApp */}
+ {isRDP && (
+
+ {t("hosts.remoteApp")}
+
+
+
+
+
+
+ )}
+
+ {/* Clipboard */}
+
+ {t("hosts.clipboardSettings")}
+
+
+
+
+
+
+
+ {/* VNC Specific */}
+ {isVNC && (
+
+ {t("hosts.vncSettings")}
+
+
+
+
+
+
+ )}
+
+ {/* Recording */}
+
+ {t("hosts.recordingSettings")}
+
+
+
+
+
+
+
+
+
+
+ {/* Wake-on-LAN */}
+
+ {t("hosts.wakeOnLan")}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatusTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatusTab.tsx
new file mode 100644
index 00000000..543dc4ad
--- /dev/null
+++ b/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatusTab.tsx
@@ -0,0 +1,143 @@
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+} from "@/components/ui/form.tsx";
+import { Input } from "@/components/ui/input.tsx";
+import { Switch } from "@/components/ui/switch.tsx";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select.tsx";
+import type { HostStatisticsTabProps } from "./shared/tab-types";
+
+export function HostStatusTab({
+ form,
+ statusIntervalUnit,
+ setStatusIntervalUnit,
+ t,
+}: Pick<
+ HostStatisticsTabProps,
+ "form" | "statusIntervalUnit" | "setStatusIntervalUnit" | "t"
+>) {
+ return (
+
+
+
+
(
+
+
+ {t("hosts.statusCheckEnabled")}
+
+ {t("hosts.statusCheckEnabledDesc")}
+
+
+
+
+
+
+ )}
+ />
+
+ {form.watch("statsConfig.statusCheckEnabled") && (
+ <>
+ (
+
+
+
+
+
+ {t("hosts.useGlobalStatusInterval")}
+
+
+ )}
+ />
+ {form.watch("statsConfig.useGlobalStatusInterval") === false && (
+ {
+ const displayValue =
+ statusIntervalUnit === "minutes"
+ ? Math.round((field.value || 30) / 60)
+ : field.value || 30;
+
+ const handleIntervalChange = (value: string) => {
+ const numValue = parseInt(value) || 0;
+ const seconds =
+ statusIntervalUnit === "minutes"
+ ? numValue * 60
+ : numValue;
+ field.onChange(seconds);
+ };
+
+ return (
+
+ {t("hosts.statusCheckInterval")}
+
+
+
+ handleIntervalChange(e.target.value)
+ }
+ className="flex-1"
+ />
+
+ {
+ setStatusIntervalUnit(value);
+ const currentSeconds = field.value || 30;
+ if (value === "minutes") {
+ const minutes = Math.round(currentSeconds / 60);
+ field.onChange(minutes * 60);
+ }
+ }}
+ >
+
+
+
+
+
+ {t("hosts.intervalSeconds")}
+
+
+ {t("hosts.intervalMinutes")}
+
+
+
+
+
+ {t("hosts.statusCheckIntervalDesc")}
+
+
+ );
+ }}
+ />
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/shared/tab-types.ts b/src/ui/desktop/apps/host-manager/hosts/tabs/shared/tab-types.ts
index 0f94dcaf..a2886189 100644
--- a/src/ui/desktop/apps/host-manager/hosts/tabs/shared/tab-types.ts
+++ b/src/ui/desktop/apps/host-manager/hosts/tabs/shared/tab-types.ts
@@ -4,6 +4,7 @@ import type { SSHHost, Credential } from "@/types";
export interface HostGeneralTabProps {
form: UseFormReturn;
+ connectionType: "ssh" | "rdp" | "vnc" | "telnet";
authTab: "password" | "key" | "credential" | "none";
setAuthTab: (value: "password" | "key" | "credential" | "none") => void;
keyInputMethod: "upload" | "paste";
@@ -75,6 +76,12 @@ export interface HostStatisticsTabProps {
t: (key: string) => string;
}
+export interface HostRemoteDesktopTabProps {
+ form: UseFormReturn;
+ connectionType: "rdp" | "vnc" | "telnet";
+ t: (key: string) => string;
+}
+
export interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
diff --git a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx
index 000292d2..b3678a5a 100644
--- a/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx
+++ b/src/ui/desktop/apps/tools/SSHToolsSidebar.tsx
@@ -251,7 +251,10 @@ export function SSHToolsSidebar({
tab.type === "server_stats" ||
tab.type === "file_manager" ||
tab.type === "tunnel" ||
- tab.type === "docker",
+ tab.type === "docker" ||
+ tab.type === "rdp" ||
+ tab.type === "vnc" ||
+ tab.type === "telnet",
);
useEffect(() => {
diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx
index 42a3a9aa..7c019ee4 100644
--- a/src/ui/desktop/authentication/Auth.tsx
+++ b/src/ui/desktop/authentication/Auth.tsx
@@ -615,7 +615,7 @@ export function Auth({
async function handleOIDCLogin() {
setOidcLoading(true);
try {
- const authResponse = await getOIDCAuthorizeUrl();
+ const authResponse = await getOIDCAuthorizeUrl(rememberMe);
const { auth_url: authUrl } = authResponse;
if (!authUrl || authUrl === "undefined") {
@@ -1236,16 +1236,30 @@ export function Auth({
);
} else {
return (
-
- {oidcLoading
- ? Spinner
- : t("auth.loginWithExternal")}
-
+ <>
+
+
+ setRememberMe(checked === true)
+ }
+ />
+
+ {t("auth.rememberMe")}
+
+
+
+ {oidcLoading
+ ? Spinner
+ : t("auth.loginWithExternal")}
+
+ >
);
}
})()}
diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx
index 27b05330..bacebaa5 100644
--- a/src/ui/desktop/navigation/AppView.tsx
+++ b/src/ui/desktop/navigation/AppView.tsx
@@ -2,6 +2,10 @@ import React, { useEffect, useRef, useState, useMemo } from "react";
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
+import {
+ GuacamoleDisplay,
+ type GuacamoleConnectionConfig,
+} from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
@@ -14,6 +18,7 @@ import {
import * as ResizablePrimitive from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { RefreshCcw } from "lucide-react";
+import { toast } from "sonner";
import { Button } from "@/components/ui/button.tsx";
import {
TERMINAL_THEMES,
@@ -34,6 +39,7 @@ interface TabData {
};
};
hostConfig?: any;
+ connectionConfig?: GuacamoleConnectionConfig;
[key: string]: unknown;
}
@@ -105,6 +111,9 @@ export function AppView({
tab.type === "terminal" ||
tab.type === "server_stats" ||
tab.type === "file_manager" ||
+ tab.type === "rdp" ||
+ tab.type === "vnc" ||
+ tab.type === "telnet" ||
tab.type === "tunnel" ||
tab.type === "docker" ||
tab.type === "network_graph",
@@ -376,6 +385,7 @@ export function AppView({
>
{t.type === "terminal" ? (
) : t.type === "server_stats" ? (
+ ) : t.type === "rdp" ||
+ t.type === "vnc" ||
+ t.type === "telnet" ? (
+ t.connectionConfig ? (
+ removeTab(t.id)}
+ onError={(err) => {
+ toast.error(err);
+ removeTab(t.id);
+ }}
+ />
+ ) : (
+
+ Missing connection configuration
+
+ )
) : t.type === "network_graph" ? (
) : t.type === "tunnel" ? (
) : t.type === "docker" ? (
) : (
removeTab(t.id)}
diff --git a/src/ui/desktop/navigation/LeftSidebar.tsx b/src/ui/desktop/navigation/LeftSidebar.tsx
index b2353889..0b1cce7f 100644
--- a/src/ui/desktop/navigation/LeftSidebar.tsx
+++ b/src/ui/desktop/navigation/LeftSidebar.tsx
@@ -36,30 +36,7 @@ import { Button } from "@/components/ui/button.tsx";
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
-import type { SSHFolder } from "@/types/index.ts";
-
-interface SSHHost {
- id: number;
- name: string;
- ip: string;
- port: number;
- username: string;
- folder: string;
- tags: string[];
- pin: boolean;
- authType: string;
- password?: string;
- key?: string;
- keyPassword?: string;
- keyType?: string;
- enableTerminal: boolean;
- enableTunnel: boolean;
- enableFileManager: boolean;
- defaultPath: string;
- tunnelConnections: unknown[];
- createdAt: string;
- updatedAt: string;
-}
+import type { SSHFolder, SSHHost } from "@/types/index.ts";
interface SidebarProps {
disabled?: boolean;
@@ -218,37 +195,41 @@ export function LeftSidebar({
setTimeout(() => {
setHosts(newHosts);
prevHostsRef.current = newHosts;
-
- newHosts.forEach((newHost) => {
- updateHostConfig(newHost.id, newHost);
- });
}, 50);
}
} catch {
setHostsError(t("leftSidebar.failedToLoadHosts"));
}
- }, [updateHostConfig]);
+ }, [t]);
+
+ const fetchHostsRef = React.useRef(fetchHosts);
+ const fetchFolderMetadataRef = React.useRef(fetchFolderMetadata);
React.useEffect(() => {
- fetchHosts();
- fetchFolderMetadata();
+ fetchHostsRef.current = fetchHosts;
+ fetchFolderMetadataRef.current = fetchFolderMetadata;
+ });
+
+ React.useEffect(() => {
+ fetchHostsRef.current();
+ fetchFolderMetadataRef.current();
const interval = setInterval(() => {
- fetchHosts();
- fetchFolderMetadata();
+ fetchHostsRef.current();
+ fetchFolderMetadataRef.current();
}, 300000);
return () => clearInterval(interval);
- }, [fetchHosts, fetchFolderMetadata]);
+ }, []);
React.useEffect(() => {
const handleHostsChanged = () => {
- fetchHosts();
- fetchFolderMetadata();
+ fetchHostsRef.current();
+ fetchFolderMetadataRef.current();
};
const handleCredentialsChanged = () => {
- fetchHosts();
+ fetchHostsRef.current();
};
const handleFoldersChanged = () => {
- fetchFolderMetadata();
+ fetchFolderMetadataRef.current();
};
window.addEventListener(
"ssh-hosts:changed",
@@ -276,7 +257,7 @@ export function LeftSidebar({
handleFoldersChanged as EventListener,
);
};
- }, [fetchHosts, fetchFolderMetadata]);
+ }, []);
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
@@ -549,7 +530,7 @@ export function LeftSidebar({
const metadata = folderMetadata.get(folder);
return (
handleTabClose(tab.id)
: undefined
@@ -512,6 +516,8 @@ export function TopNavbar({
isSshManager ||
isAdmin ||
isUserProfile ||
+ isRdp ||
+ isVnc ||
tab.type === "network_graph"
}
disableActivate={disableActivate}
diff --git a/src/ui/desktop/navigation/hosts/Host.tsx b/src/ui/desktop/navigation/hosts/Host.tsx
index ccbc7c6a..26c088f6 100644
--- a/src/ui/desktop/navigation/hosts/Host.tsx
+++ b/src/ui/desktop/navigation/hosts/Host.tsx
@@ -5,6 +5,9 @@ import { ButtonGroup } from "@/components/ui/button-group";
import {
EllipsisVertical,
Terminal,
+ Monitor,
+ Eye,
+ MessagesSquare,
Server,
FolderOpen,
Pencil,
@@ -18,7 +21,7 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
-import { getSSHHosts } from "@/ui/main-axios";
+import { getSSHHosts, getGuacamoleToken, logActivity } from "@/ui/main-axios";
import type { HostProps } from "../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { useTranslation } from "react-i18next";
@@ -44,10 +47,16 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
setHost(initialHost);
}, [initialHost]);
- useEffect(() => {
+ const hostIdRef = React.useRef(host.id);
+
+ React.useEffect(() => {
+ hostIdRef.current = host.id;
+ });
+
+ React.useEffect(() => {
const handleHostsChanged = async () => {
const hosts = await getSSHHosts();
- const updatedHost = hosts.find((h) => h.id === host.id);
+ const updatedHost = hosts.find((h) => h.id === hostIdRef.current);
if (updatedHost) {
setHost(updatedHost);
}
@@ -56,7 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
- }, [host.id]);
+ }, []);
useEffect(() => {
const handleShowTagsChanged = () => {
@@ -70,16 +79,21 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}, []);
const statsConfig = useMemo(() => {
+ if (!host.statsConfig) {
+ return DEFAULT_STATS_CONFIG;
+ }
+ if (typeof host.statsConfig === "object") {
+ return host.statsConfig;
+ }
try {
- return host.statsConfig
- ? JSON.parse(host.statsConfig)
- : DEFAULT_STATS_CONFIG;
- } catch {
+ return JSON.parse(host.statsConfig);
+ } catch (e) {
return DEFAULT_STATS_CONFIG;
}
}, [host.statsConfig]);
-
- const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
+ const shouldShowStatus = ![false, "false"].includes(
+ statsConfig.statusCheckEnabled,
+ );
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
const serverStatus = useHostStatus(host.id, shouldShowStatus);
@@ -96,18 +110,67 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}
}, [host.tunnelConnections]);
- const handleTerminalClick = () => {
+ const handleTerminalClick = async () => {
+ if (
+ host.connectionType === "rdp" ||
+ host.connectionType === "vnc" ||
+ host.connectionType === "telnet"
+ ) {
+ try {
+ const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
+ const result = await getGuacamoleToken({
+ protocol,
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ ignoreCert: host.ignoreCert,
+ guacamoleConfig: host.guacamoleConfig as any,
+ });
+ addTab({
+ type: protocol,
+ title,
+ hostConfig: host,
+ connectionConfig: {
+ token: result.token,
+ protocol,
+ type: protocol,
+ hostname: host.ip,
+ port: host.port,
+ username: host.username,
+ password: host.password,
+ domain: host.domain,
+ security: host.security,
+ "ignore-cert": host.ignoreCert,
+ },
+ });
+
+ try {
+ await logActivity(protocol, host.id, title);
+ } catch (err) {
+ console.warn(`Failed to log ${protocol} activity:`, err);
+ }
+ } catch (err) {
+ console.error("Failed to get Guacamole token:", err);
+ }
+ return;
+ }
addTab({ type: "terminal", title, hostConfig: host });
};
+ const isSSH = !host.connectionType || host.connectionType === "ssh";
+
const visibleButtons = [
host.enableTerminal && (host.showTerminalInSidebar ?? true),
- host.enableFileManager && (host.showFileManagerInSidebar ?? false),
- host.enableTunnel &&
+ isSSH && host.enableFileManager && (host.showFileManagerInSidebar ?? false),
+ isSSH &&
+ host.enableTunnel &&
hasTunnelConnections &&
(host.showTunnelInSidebar ?? false),
- host.enableDocker && (host.showDockerInSidebar ?? false),
- shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
+ isSSH && host.enableDocker && (host.showDockerInSidebar ?? false),
+ isSSH && shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
].filter(Boolean).length;
return (
@@ -133,11 +196,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
className="!px-2 border-1 border-edge"
onClick={handleTerminalClick}
>
-
+ {host.connectionType === "rdp" ? (
+
+ ) : host.connectionType === "vnc" ? (
+
+ ) : host.connectionType === "telnet" ? (
+
+ ) : (
+
+ )}
)}
- {host.enableFileManager &&
+ {isSSH &&
+ host.enableFileManager &&
(host.showFileManagerInSidebar ?? false) && (
)}
- {host.enableTunnel &&
+ {isSSH &&
+ host.enableTunnel &&
hasTunnelConnections &&
(host.showTunnelInSidebar ?? false) && (
)}
- {host.enableDocker && (host.showDockerInSidebar ?? false) && (
-
- addTab({ type: "docker", title, hostConfig: host })
- }
- >
-
-
- )}
+ {isSSH &&
+ host.enableDocker &&
+ (host.showDockerInSidebar ?? false) && (
+
+ addTab({ type: "docker", title, hostConfig: host })
+ }
+ >
+
+
+ )}
- {shouldShowMetrics && (host.showServerStatsInSidebar ?? false) && (
-
- addTab({ type: "server_stats", title, hostConfig: host })
- }
- >
-
-
- )}
+ {isSSH &&
+ shouldShowMetrics &&
+ (host.showServerStatsInSidebar ?? false) && (
+
+ addTab({ type: "server_stats", title, hostConfig: host })
+ }
+ >
+
+
+ )}
@@ -211,11 +288,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
onClick={handleTerminalClick}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
-
+ {host.connectionType === "rdp" ? (
+
+ ) : host.connectionType === "vnc" ? (
+
+ ) : host.connectionType === "telnet" ? (
+
+ ) : (
+
+ )}
{t("hosts.openTerminal")}
)}
- {shouldShowMetrics &&
+ {isSSH &&
+ shouldShowMetrics &&
!(host.showServerStatsInSidebar ?? false) && (
@@ -227,7 +313,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
{t("hosts.openServerStats")}
)}
- {host.enableFileManager &&
+ {isSSH &&
+ host.enableFileManager &&
!(host.showFileManagerInSidebar ?? false) && (
@@ -239,7 +326,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
{t("hosts.openFileManager")}
)}
- {host.enableTunnel &&
+ {isSSH &&
+ host.enableTunnel &&
hasTunnelConnections &&
!(host.showTunnelInSidebar ?? false) && (
{t("hosts.openTunnels")}
)}
- {host.enableDocker && !(host.showDockerInSidebar ?? false) && (
-
- addTab({ type: "docker", title, hostConfig: host })
- }
- className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
- >
-
- {t("hosts.openDocker")}
-
- )}
+ {isSSH &&
+ host.enableDocker &&
+ !(host.showDockerInSidebar ?? false) && (
+
+ addTab({ type: "docker", title, hostConfig: host })
+ }
+ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
+ >
+
+ {t("hosts.openDocker")}
+
+ )}
addTab({
diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx
index 659e7942..ddc32c96 100644
--- a/src/ui/desktop/navigation/tabs/Tab.tsx
+++ b/src/ui/desktop/navigation/tabs/Tab.tsx
@@ -10,6 +10,9 @@ import {
Server as ServerIcon,
Folder as FolderIcon,
User as UserIcon,
+ Monitor as MonitorIcon,
+ Eye as EyeIcon,
+ MessagesSquare as MessageSquareIcon,
Network,
ArrowDownUp as TunnelIcon,
Container as DockerIcon,
@@ -172,6 +175,9 @@ export function Tab({
tabType === "terminal" ||
tabType === "server_stats" ||
tabType === "file_manager" ||
+ tabType === "rdp" ||
+ tabType === "vnc" ||
+ tabType === "telnet" ||
tabType === "tunnel" ||
tabType === "docker" ||
tabType === "user_profile"
@@ -181,7 +187,6 @@ export function Tab({
const isTunnel = tabType === "tunnel";
const isDocker = tabType === "docker";
const isUserProfile = tabType === "user_profile";
-
const displayTitle =
title ||
(isServer
@@ -194,7 +199,9 @@ export function Tab({
? t("nav.docker")
: isUserProfile
? t("nav.userProfile")
- : t("nav.terminal"));
+ : tabType === "rdp" || tabType === "vnc" || tabType === "telnet"
+ ? tabType.toUpperCase()
+ : t("nav.terminal"));
const { base, suffix } = splitTitle(displayTitle);
@@ -219,6 +226,12 @@ export function Tab({
) : isUserProfile ? (
+ ) : tabType === "rdp" ? (
+
+ ) : tabType === "vnc" ? (
+
+ ) : tabType === "telnet" ? (
+
) : (
)}
diff --git a/src/ui/desktop/navigation/tabs/TabContext.tsx b/src/ui/desktop/navigation/tabs/TabContext.tsx
index fb84cce3..944e4cd2 100644
--- a/src/ui/desktop/navigation/tabs/TabContext.tsx
+++ b/src/ui/desktop/navigation/tabs/TabContext.tsx
@@ -4,6 +4,8 @@ import React, {
useState,
useEffect,
useRef,
+ useCallback,
+ useMemo,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
@@ -51,7 +53,6 @@ interface TabProviderProps {
export function clearTermixSessionStorage() {
localStorage.removeItem("termix_tabs");
localStorage.removeItem("termix_currentTab");
- // Clear all session keys
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
@@ -65,7 +66,6 @@ export function clearTermixSessionStorage() {
export function TabProvider({ children }: TabProviderProps) {
const { t } = useTranslation();
const [tabs, setTabs] = useState(() => {
- // Check if persistence is enabled (always enabled for mobile/Electron)
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
const isElectron =
typeof window !== "undefined" && !!(window as any).electronAPI;
@@ -85,10 +85,9 @@ export function TabProvider({ children }: TabProviderProps) {
let maxId = 1;
for (const tab of parsed) {
if (tab.type === "home") continue;
- // Preserve instanceId from saved tab - critical for session lookup
const restoredTab: Tab = {
...tab,
- instanceId: tab.instanceId, // Preserve instanceId for session persistence
+ instanceId: tab.instanceId,
terminalRef:
tab.type === "terminal"
? React.createRef<{ disconnect?: () => void }>()
@@ -115,7 +114,6 @@ export function TabProvider({ children }: TabProviderProps) {
const saved = localStorage.getItem("termix_currentTab");
if (saved) {
const parsed = parseInt(saved, 10);
- // Validate against restored tabs
if (parsed && tabs.some((t) => t.id === parsed)) return parsed;
}
} catch {
@@ -133,9 +131,7 @@ export function TabProvider({ children }: TabProviderProps) {
});
const nextTabId = useRef(initialMaxId);
- // Save tabs to localStorage on change
useEffect(() => {
- // Check if persistence is enabled (always enabled for mobile/Electron)
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
const isElectron =
typeof window !== "undefined" && !!(window as any).electronAPI;
@@ -144,14 +140,12 @@ export function TabProvider({ children }: TabProviderProps) {
const shouldSave = isMobile || isElectron || persistenceEnabled;
if (shouldSave) {
- // Serialize tabs, preserving instanceId for session persistence
const serializable = tabs
.filter((t) => t.type !== "home")
.map(({ terminalRef, ...rest }) => rest);
localStorage.setItem("termix_tabs", JSON.stringify(serializable));
localStorage.setItem("termix_currentTab", String(currentTab));
} else {
- // Clear saved tabs when persistence is disabled
localStorage.removeItem("termix_tabs");
localStorage.removeItem("termix_currentTab");
}
@@ -346,72 +340,92 @@ export function TabProvider({ children }: TabProviderProps) {
});
};
- const updateHostConfig = (
- hostId: number,
- newHostConfig: {
- id: number;
- name?: string;
- username: string;
- ip: string;
- port: number;
- },
- ) => {
- setTabs((prev) =>
- prev.map((tab) => {
- if (tab.hostConfig && tab.hostConfig.id === hostId) {
- if (tab.type === "ssh_manager") {
+ const updateHostConfig = useCallback(
+ (
+ hostId: number,
+ newHostConfig: {
+ id: number;
+ name?: string;
+ username: string;
+ ip: string;
+ port: number;
+ },
+ ) => {
+ setTabs((prev) =>
+ prev.map((tab) => {
+ if (tab.hostConfig && tab.hostConfig.id === hostId) {
+ if (tab.type === "ssh_manager") {
+ return {
+ ...tab,
+ hostConfig: {
+ ...newHostConfig,
+ instanceId: tab.hostConfig.instanceId,
+ },
+ };
+ }
+
return {
...tab,
hostConfig: {
...newHostConfig,
- instanceId: tab.hostConfig.instanceId, // Preserve instanceId for session persistence
+ instanceId: tab.hostConfig.instanceId,
},
+ title: newHostConfig.name?.trim()
+ ? newHostConfig.name
+ : t("nav.hostTabTitle", {
+ username: newHostConfig.username,
+ ip: newHostConfig.ip,
+ port: newHostConfig.port,
+ }),
};
}
+ return tab;
+ }),
+ );
+ },
+ [t],
+ );
- return {
- ...tab,
- hostConfig: {
- ...newHostConfig,
- instanceId: tab.hostConfig.instanceId, // Preserve instanceId for session persistence
- },
- title: newHostConfig.name?.trim()
- ? newHostConfig.name
- : t("nav.hostTabTitle", {
- username: newHostConfig.username,
- ip: newHostConfig.ip,
- port: newHostConfig.port,
- }),
- };
- }
- return tab;
- }),
- );
- };
+ const updateTab = useCallback(
+ (tabId: number, updates: Partial>) => {
+ setTabs((prev) =>
+ prev.map((tab) =>
+ tab.id === tabId
+ ? { ...tab, ...updates, _updateTimestamp: Date.now() }
+ : tab,
+ ),
+ );
+ },
+ [],
+ );
- const updateTab = (tabId: number, updates: Partial>) => {
- setTabs((prev) =>
- prev.map((tab) =>
- tab.id === tabId
- ? { ...tab, ...updates, _updateTimestamp: Date.now() }
- : tab,
- ),
- );
- };
-
- const value: TabContextType = {
- tabs,
- currentTab,
- allSplitScreenTab,
- addTab,
- removeTab,
- setCurrentTab,
- setSplitScreenTab,
- getTab,
- reorderTabs,
- updateHostConfig,
- updateTab,
- };
+ const value: TabContextType = useMemo(
+ () => ({
+ tabs,
+ currentTab,
+ allSplitScreenTab,
+ addTab,
+ removeTab,
+ setCurrentTab,
+ setSplitScreenTab,
+ getTab,
+ reorderTabs,
+ updateHostConfig,
+ updateTab,
+ }),
+ [
+ tabs,
+ currentTab,
+ allSplitScreenTab,
+ addTab,
+ removeTab,
+ setSplitScreenTab,
+ getTab,
+ reorderTabs,
+ updateHostConfig,
+ updateTab,
+ ],
+ );
return {children} ;
}
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts
index a3fe5605..367ef581 100644
--- a/src/ui/main-axios.ts
+++ b/src/ui/main-axios.ts
@@ -731,8 +731,9 @@ function getApiUrl(path: string, defaultPort: number): string {
}
function initializeApiInstances() {
- // SSH Host Management API (port 30001)
- sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST");
+ // Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
+ hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
+ sshHostApi = hostApi;
// Tunnel Management API (port 30003)
tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL");
@@ -759,7 +760,9 @@ function initializeApiInstances() {
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
}
-// SSH Host Management API (port 30001)
+// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
+export let hostApi: AxiosInstance;
+// Backward compatibility
export let sshHostApi: AxiosInstance;
// Tunnel Management API (port 30003)
@@ -1020,6 +1023,7 @@ export async function getSSHHosts(): Promise {
export async function createSSHHost(hostData: SSHHostData): Promise {
try {
const submitData = {
+ connectionType: hostData.connectionType || "ssh",
name: hostData.name || "",
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
@@ -1028,7 +1032,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise {
tags: hostData.tags || [],
pin: Boolean(hostData.pin),
authType: hostData.authType,
- password: hostData.authType === "password" ? hostData.password : null,
+ password:
+ hostData.connectionType !== "ssh"
+ ? hostData.password || null
+ : hostData.authType === "password"
+ ? hostData.password
+ : null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
keyType: hostData.authType === "key" ? hostData.keyType : null,
@@ -1050,8 +1059,13 @@ export async function createSSHHost(hostData: SSHHostData): Promise {
quickActions: hostData.quickActions || [],
sudoPassword: hostData.sudoPassword || null,
statsConfig: hostData.statsConfig || null,
+ dockerConfig: hostData.dockerConfig || null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
+ domain: hostData.domain || null,
+ security: hostData.security || null,
+ ignoreCert: Boolean(hostData.ignoreCert),
+ guacamoleConfig: hostData.guacamoleConfig || null,
notes: hostData.notes || "",
useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null,
@@ -1096,6 +1110,7 @@ export async function updateSSHHost(
): Promise {
try {
const submitData = {
+ connectionType: hostData.connectionType || "ssh",
name: hostData.name || "",
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
@@ -1104,7 +1119,12 @@ export async function updateSSHHost(
tags: hostData.tags || [],
pin: Boolean(hostData.pin),
authType: hostData.authType,
- password: hostData.authType === "password" ? hostData.password : null,
+ password:
+ hostData.connectionType !== "ssh"
+ ? hostData.password || null
+ : hostData.authType === "password"
+ ? hostData.password
+ : null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
keyType: hostData.authType === "key" ? hostData.keyType : null,
@@ -1126,8 +1146,13 @@ export async function updateSSHHost(
quickActions: hostData.quickActions || [],
sudoPassword: hostData.sudoPassword || null,
statsConfig: hostData.statsConfig || null,
+ dockerConfig: hostData.dockerConfig || null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
+ domain: hostData.domain || null,
+ security: hostData.security || null,
+ ignoreCert: Boolean(hostData.ignoreCert),
+ guacamoleConfig: hostData.guacamoleConfig || null,
notes: hostData.notes || "",
useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null,
@@ -1511,7 +1536,6 @@ export async function connectSSH(
});
return response.data;
} catch (error: any) {
- // Preserve connection logs from error response
if (error?.response?.data?.connectionLogs) {
const errorWithLogs = new Error(
error?.response?.data?.error ||
@@ -1520,7 +1544,6 @@ export async function connectSSH(
);
(errorWithLogs as any).connectionLogs =
error.response.data.connectionLogs;
- // Also preserve other fields like requires_totp
if (error.response.data.requires_totp) {
(errorWithLogs as any).requires_totp = true;
(errorWithLogs as any).sessionId = error.response.data.sessionId;
@@ -1653,7 +1676,7 @@ export async function quickConnect(
data: Record,
): Promise {
try {
- const response = await authApi.post("/ssh/quick-connect", data);
+ const response = await authApi.post("/host/quick-connect", data);
return response.data;
} catch (error) {
throw handleApiError(error, "quick connect");
@@ -2135,7 +2158,7 @@ export async function getRecentFiles(
hostId: number,
): Promise> {
try {
- const response = await authApi.get("/ssh/file_manager/recent", {
+ const response = await authApi.get("/host/file_manager/recent", {
params: { hostId },
});
return response.data;
@@ -2151,7 +2174,7 @@ export async function addRecentFile(
name?: string,
): Promise> {
try {
- const response = await authApi.post("/ssh/file_manager/recent", {
+ const response = await authApi.post("/host/file_manager/recent", {
hostId,
path,
name,
@@ -2168,7 +2191,7 @@ export async function removeRecentFile(
path: string,
): Promise> {
try {
- const response = await authApi.delete("/ssh/file_manager/recent", {
+ const response = await authApi.delete("/host/file_manager/recent", {
data: { hostId, path },
});
return response.data;
@@ -2182,7 +2205,7 @@ export async function getPinnedFiles(
hostId: number,
): Promise> {
try {
- const response = await authApi.get("/ssh/file_manager/pinned", {
+ const response = await authApi.get("/host/file_manager/pinned", {
params: { hostId },
});
return response.data;
@@ -2198,7 +2221,7 @@ export async function addPinnedFile(
name?: string,
): Promise> {
try {
- const response = await authApi.post("/ssh/file_manager/pinned", {
+ const response = await authApi.post("/host/file_manager/pinned", {
hostId,
path,
name,
@@ -2215,7 +2238,7 @@ export async function removePinnedFile(
path: string,
): Promise> {
try {
- const response = await authApi.delete("/ssh/file_manager/pinned", {
+ const response = await authApi.delete("/host/file_manager/pinned", {
data: { hostId, path },
});
return response.data;
@@ -2229,7 +2252,7 @@ export async function getFolderShortcuts(
hostId: number,
): Promise> {
try {
- const response = await authApi.get("/ssh/file_manager/shortcuts", {
+ const response = await authApi.get("/host/file_manager/shortcuts", {
params: { hostId },
});
return response.data;
@@ -2245,7 +2268,7 @@ export async function addFolderShortcut(
name?: string,
): Promise> {
try {
- const response = await authApi.post("/ssh/file_manager/shortcuts", {
+ const response = await authApi.post("/host/file_manager/shortcuts", {
hostId,
path,
name,
@@ -2262,7 +2285,7 @@ export async function removeFolderShortcut(
path: string,
): Promise> {
try {
- const response = await authApi.delete("/ssh/file_manager/shortcuts", {
+ const response = await authApi.delete("/host/file_manager/shortcuts", {
data: { hostId, path },
});
return response.data;
@@ -2319,7 +2342,6 @@ export async function startMetricsPolling(hostId: number): Promise<{
const response = await statsApi.post(`/metrics/start/${hostId}`);
return response.data;
} catch (error: any) {
- // Preserve connection logs from error response
if (error?.response?.data?.connectionLogs) {
const errorWithLogs = new Error(
error?.response?.data?.error || error.message,
@@ -2449,6 +2471,33 @@ export async function updateGlobalMonitoringSettings(settings: {
}
}
+// ============================================================================
+// GUACAMOLE SETTINGS
+// ============================================================================
+
+export async function getGuacamoleSettings(): Promise<{
+ enabled: boolean;
+ url: string;
+}> {
+ try {
+ const response = await authApi.get("/users/guacamole-settings");
+ return response.data;
+ } catch (error) {
+ handleApiError(error, "fetch guacamole settings");
+ }
+}
+
+export async function updateGuacamoleSettings(settings: {
+ enabled?: boolean;
+ url?: string;
+}): Promise {
+ try {
+ await authApi.patch("/users/guacamole-settings", settings);
+ } catch (error) {
+ handleApiError(error, "update guacamole settings");
+ }
+}
+
// ============================================================================
// AUTHENTICATION
// ============================================================================
@@ -2708,9 +2757,13 @@ export async function changePassword(oldPassword: string, newPassword: string) {
}
}
-export async function getOIDCAuthorizeUrl(): Promise {
+export async function getOIDCAuthorizeUrl(
+ rememberMe = false,
+): Promise {
try {
- const response = await authApi.get("/users/oidc/authorize");
+ const response = await authApi.get("/users/oidc/authorize", {
+ params: { rememberMe },
+ });
return response.data;
} catch (error) {
handleApiError(error, "get OIDC authorize URL");
@@ -3201,7 +3254,7 @@ export async function migrateHostToCredential(
export async function getFoldersWithStats(): Promise> {
try {
- const response = await authApi.get("/ssh/db/folders/with-stats");
+ const response = await authApi.get("/host/db/folders/with-stats");
return response.data;
} catch (error) {
handleApiError(error, "fetch folders with statistics");
@@ -3213,7 +3266,7 @@ export async function renameFolder(
newName: string,
): Promise> {
try {
- const response = await authApi.put("/ssh/folders/rename", {
+ const response = await authApi.put("/host/folders/rename", {
oldName,
newName,
});
@@ -3229,7 +3282,7 @@ export async function getSSHFolders(): Promise {
operation: "fetch_ssh_folders",
});
- const response = await authApi.get("/ssh/folders");
+ const response = await authApi.get("/host/folders");
sshLogger.success("SSH folders fetched successfully", {
operation: "fetch_ssh_folders",
@@ -3259,7 +3312,7 @@ export async function updateFolderMetadata(
icon,
});
- await authApi.put("/ssh/folders/metadata", {
+ await authApi.put("/host/folders/metadata", {
name,
color,
icon,
@@ -3289,7 +3342,7 @@ export async function deleteAllHostsInFolder(
});
const response = await authApi.delete(
- `/ssh/folders/${encodeURIComponent(folderName)}/hosts`,
+ `/host/folders/${encodeURIComponent(folderName)}/hosts`,
);
sshLogger.success("All hosts in folder deleted successfully", {
@@ -3596,7 +3649,15 @@ export interface UptimeInfo {
export interface RecentActivityItem {
id: number;
userId: string;
- type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker";
+ type:
+ | "terminal"
+ | "file_manager"
+ | "server_stats"
+ | "tunnel"
+ | "docker"
+ | "telnet"
+ | "vnc"
+ | "rdp";
hostId: number;
hostName: string;
timestamp: string;
@@ -3625,7 +3686,15 @@ export async function getRecentActivity(
}
export async function logActivity(
- type: "terminal" | "file_manager" | "server_stats" | "tunnel" | "docker",
+ type:
+ | "terminal"
+ | "file_manager"
+ | "server_stats"
+ | "tunnel"
+ | "docker"
+ | "rdp"
+ | "vnc"
+ | "telnet",
hostId: number,
hostName: string,
): Promise<{ message: string; id: number | string }> {
@@ -3743,11 +3812,193 @@ export async function unlinkOIDCFromPasswordAccount(
}
}
+export interface GuacamoleTokenRequest {
+ protocol: "rdp" | "vnc" | "telnet";
+ hostname: string;
+ port?: number;
+ username?: string;
+ password?: string;
+ domain?: string;
+ security?: string;
+ ignoreCert?: boolean;
+ guacamoleConfig?: {
+ colorDepth?: number;
+ width?: number;
+ height?: number;
+ dpi?: number;
+ resizeMethod?: string;
+ forceLossless?: boolean;
+ disableAudio?: boolean;
+ enableAudioInput?: boolean;
+ enableWallpaper?: boolean;
+ enableTheming?: boolean;
+ enableFontSmoothing?: boolean;
+ enableFullWindowDrag?: boolean;
+ enableDesktopComposition?: boolean;
+ enableMenuAnimations?: boolean;
+ disableBitmapCaching?: boolean;
+ disableOffscreenCaching?: boolean;
+ disableGlyphCaching?: boolean;
+ disableGfx?: boolean;
+ enablePrinting?: boolean;
+ printerName?: string;
+ enableDrive?: boolean;
+ driveName?: string;
+ drivePath?: string;
+ createDrivePath?: boolean;
+ disableDownload?: boolean;
+ disableUpload?: boolean;
+ enableTouch?: boolean;
+ clientName?: string;
+ console?: boolean;
+ initialProgram?: string;
+ serverLayout?: string;
+ timezone?: string;
+ gatewayHostname?: string;
+ gatewayPort?: number;
+ gatewayUsername?: string;
+ gatewayPassword?: string;
+ gatewayDomain?: string;
+ remoteApp?: string;
+ remoteAppDir?: string;
+ remoteAppArgs?: string;
+ normalizeClipboard?: string;
+ disableCopy?: boolean;
+ disablePaste?: boolean;
+ cursor?: string;
+ swapRedBlue?: boolean;
+ readOnly?: boolean;
+ recordingPath?: string;
+ recordingName?: string;
+ createRecordingPath?: boolean;
+ recordingExcludeOutput?: boolean;
+ recordingExcludeMouse?: boolean;
+ recordingIncludeKeys?: boolean;
+ wolSendPacket?: boolean;
+ wolMacAddr?: string;
+ wolBroadcastAddr?: string;
+ wolUdpPort?: number;
+ wolWaitTime?: number;
+ };
+}
+
+export interface GuacamoleTokenResponse {
+ token: string;
+}
+
+function toGuacamoleParams(
+ config: GuacamoleTokenRequest["guacamoleConfig"],
+): Record {
+ if (!config) return {};
+
+ const params: Record = {};
+
+ const mappings: Record = {
+ colorDepth: "color-depth",
+ resizeMethod: "resize-method",
+ forceLossless: "force-lossless",
+ disableAudio: "disable-audio",
+ enableAudioInput: "enable-audio-input",
+ enableWallpaper: "enable-wallpaper",
+ enableTheming: "enable-theming",
+ enableFontSmoothing: "enable-font-smoothing",
+ enableFullWindowDrag: "enable-full-window-drag",
+ enableDesktopComposition: "enable-desktop-composition",
+ enableMenuAnimations: "enable-menu-animations",
+ disableBitmapCaching: "disable-bitmap-caching",
+ disableOffscreenCaching: "disable-offscreen-caching",
+ disableGlyphCaching: "disable-glyph-caching",
+ disableGfx: "disable-gfx",
+ enablePrinting: "enable-printing",
+ printerName: "printer-name",
+ enableDrive: "enable-drive",
+ driveName: "drive-name",
+ drivePath: "drive-path",
+ createDrivePath: "create-drive-path",
+ disableDownload: "disable-download",
+ disableUpload: "disable-upload",
+ enableTouch: "enable-touch",
+ clientName: "client-name",
+ initialProgram: "initial-program",
+ serverLayout: "server-layout",
+ gatewayHostname: "gateway-hostname",
+ gatewayPort: "gateway-port",
+ gatewayUsername: "gateway-username",
+ gatewayPassword: "gateway-password",
+ gatewayDomain: "gateway-domain",
+ remoteApp: "remote-app",
+ remoteAppDir: "remote-app-dir",
+ remoteAppArgs: "remote-app-args",
+ normalizeClipboard: "normalize-clipboard",
+ disableCopy: "disable-copy",
+ disablePaste: "disable-paste",
+ swapRedBlue: "swap-red-blue",
+ readOnly: "read-only",
+ recordingPath: "recording-path",
+ recordingName: "recording-name",
+ createRecordingPath: "create-recording-path",
+ recordingExcludeOutput: "recording-exclude-output",
+ recordingExcludeMouse: "recording-exclude-mouse",
+ recordingIncludeKeys: "recording-include-keys",
+ wolSendPacket: "wol-send-packet",
+ wolMacAddr: "wol-mac-addr",
+ wolBroadcastAddr: "wol-broadcast-addr",
+ wolUdpPort: "wol-udp-port",
+ wolWaitTime: "wol-wait-time",
+ };
+
+ for (const [key, value] of Object.entries(config)) {
+ if (value !== undefined && value !== null && value !== "") {
+ const paramName = mappings[key] || key;
+ if (typeof value === "boolean") {
+ params[paramName] = value ? "true" : "false";
+ } else {
+ params[paramName] = value;
+ }
+ }
+ }
+
+ return params;
+}
+
+export async function getGuacamoleToken(
+ request: GuacamoleTokenRequest,
+): Promise {
+ try {
+ const guacParams = toGuacamoleParams(request.guacamoleConfig);
+
+ const response = await authApi.post("/guacamole/token", {
+ type: request.protocol,
+ hostname: request.hostname,
+ port: request.port,
+ username: request.username,
+ password: request.password,
+ domain: request.domain,
+ security: request.security,
+ "ignore-cert": request.ignoreCert,
+ ...guacParams,
+ });
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "get guacamole token");
+ }
+}
+
+export async function getGuacamoleTokenFromHost(
+ hostId: number,
+): Promise {
+ try {
+ const response = await authApi.post(`/guacamole/connect-host/${hostId}`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "get guacamole token from host");
+ }
+}
+
// ============================================================================
// RBAC MANAGEMENT
// ============================================================================
-// Role Management
export async function getRoles(): Promise<{ roles: Role[] }> {
try {
const response = await rbacApi.get("/rbac/roles");
@@ -3796,7 +4047,6 @@ export async function deleteRole(
}
}
-// User-Role Management
export async function getUserRoles(
userId: string,
): Promise<{ roles: UserRole[] }> {
@@ -3836,14 +4086,13 @@ export async function removeRoleFromUser(
}
}
-// Host Sharing Management
export async function shareHost(
hostId: number,
shareData: {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
- permissionLevel: "view"; // Only view permission is supported
+ permissionLevel: "view";
durationHours?: number;
},
): Promise<{ success: boolean }> {
@@ -3932,7 +4181,6 @@ export async function connectDockerSession(
if (error.response?.data?.requires_warpgate) {
return error.response.data;
}
- // Preserve connection logs from error response
if (error?.response?.data?.connectionLogs) {
const errorWithLogs = new Error(
error?.response?.data?.error ||
diff --git a/src/ui/mobile/authentication/Auth.tsx b/src/ui/mobile/authentication/Auth.tsx
index f691b6e4..87e9eef2 100644
--- a/src/ui/mobile/authentication/Auth.tsx
+++ b/src/ui/mobile/authentication/Auth.tsx
@@ -549,7 +549,7 @@ export function Auth({
setError(null);
setOidcLoading(true);
try {
- const authResponse = await getOIDCAuthorizeUrl();
+ const authResponse = await getOIDCAuthorizeUrl(rememberMe);
const { auth_url: authUrl } = authResponse;
if (!authUrl || authUrl === "undefined") {
@@ -908,6 +908,18 @@ export function Auth({
{t("auth.loginWithExternalDesc")}
+
+
+ setRememberMe(checked === true)
+ }
+ />
+
+ {t("auth.rememberMe")}
+
+
{
- if (!debouncedSearch.trim()) return hosts;
+ const sshOnlyHosts = hosts.filter((h) => h.connectionType === "ssh");
+ if (!debouncedSearch.trim()) return sshOnlyHosts;
const q = debouncedSearch.trim().toLowerCase();
- return hosts.filter((h) => {
+ return sshOnlyHosts.filter((h) => {
const searchableText = [
h.name || "",
h.username,