From 42e27e7389d49a75972a58d788072ee2002755c9 Mon Sep 17 00:00:00 2001 From: starhound Date: Wed, 17 Dec 2025 15:47:42 -0500 Subject: [PATCH 1/8] feat: add Guacamole support for RDP, VNC, and Telnet connections - Implemented WebSocket support for Guacamole in Nginx configuration. - Added REST API endpoints for generating connection tokens and checking guacd status. - Created Guacamole server using guacamole-lite for handling connections. - Developed frontend components for testing RDP/VNC connections and displaying the remote session. - Updated package dependencies to include guacamole-common-js and guacamole-lite. - Enhanced logging for Guacamole operations. --- docker/docker-compose.yml | 49 +++ docker/nginx-https.conf | 35 ++ docker/nginx.conf | 35 ++ package-lock.json | 29 +- package.json | 3 + src/backend/database/database.ts | 2 + src/backend/guacamole/guacamole-server.ts | 96 ++++++ src/backend/guacamole/routes.ts | 141 ++++++++ src/backend/guacamole/token-service.ts | 198 +++++++++++ src/backend/starter.ts | 13 + src/backend/utils/logger.ts | 1 + src/ui/desktop/apps/dashboard/Dashboard.tsx | 18 + .../apps/guacamole/GuacamoleDisplay.tsx | 313 ++++++++++++++++++ .../apps/guacamole/GuacamoleTestDialog.tsx | 194 +++++++++++ 14 files changed, 1125 insertions(+), 2 deletions(-) create mode 100644 docker/docker-compose.yml create mode 100644 src/backend/guacamole/guacamole-server.ts create mode 100644 src/backend/guacamole/routes.ts create mode 100644 src/backend/guacamole/token-service.ts create mode 100644 src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx create mode 100644 src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..0473887f --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,49 @@ +services: + termix: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: termix + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - termix_data:/app/db/data + environment: + - NODE_ENV=production + - PORT=8080 + - GUACD_HOST=guacd + - GUACD_PORT=4822 + - ENABLE_GUACAMOLE=true + depends_on: + - guacd + networks: + - termix-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + guacd: + image: guacamole/guacd:latest + container_name: termix-guacd + restart: unless-stopped + networks: + - termix-network + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "4822"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +networks: + termix-network: + driver: bridge + +volumes: + termix_data: + driver: local + diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 5e6126bf..af316514 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -203,6 +203,41 @@ http { proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; } + # Guacamole WebSocket for RDP/VNC/Telnet + # ^~ modifier ensures this takes precedence over the regex location below + location ^~ /guacamole/websocket/ { + proxy_pass http://127.0.0.1:30007/; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $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_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; + } + + # Guacamole REST API + 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 /ssh/tunnel/ { proxy_pass http://127.0.0.1:30003; proxy_http_version 1.1; diff --git a/docker/nginx.conf b/docker/nginx.conf index db5546f0..85de4587 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -200,6 +200,41 @@ http { proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; } + # Guacamole WebSocket for RDP/VNC/Telnet + # ^~ modifier ensures this takes precedence over the regex location below + location ^~ /guacamole/websocket/ { + proxy_pass http://127.0.0.1:30007/; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $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_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; + } + + # Guacamole REST API + 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 /ssh/tunnel/ { proxy_pass http://127.0.0.1:30003; proxy_http_version 1.1; diff --git a/package-lock.json b/package-lock.json index 14f680ed..786306ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "1.8.1", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "1.8.1", + "version": "1.9.0", "dependencies": { "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.3.3", @@ -33,6 +33,7 @@ "@tailwindcss/vite": "^4.1.14", "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.9", + "@types/guacamole-common-js": "^1.5.5", "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", @@ -57,6 +58,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", "i18next": "^25.4.2", "i18next-browser-languagedetector": "^8.2.0", "jose": "^5.2.3", @@ -5030,6 +5033,11 @@ "@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==" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -9812,6 +9820,23 @@ "dev": true, "license": "MIT" }, + "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==" + }, + "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==", + "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 a26dd5f8..5454ee06 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@tailwindcss/vite": "^4.1.14", "@types/bcryptjs": "^2.4.6", "@types/cookie-parser": "^1.4.9", + "@types/guacamole-common-js": "^1.5.5", "@types/jszip": "^3.4.0", "@types/multer": "^2.0.0", "@types/qrcode": "^1.5.5", @@ -76,6 +77,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", "i18next": "^25.4.2", "i18next-browser-languagedetector": "^8.2.0", "jose": "^5.2.3", diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 1eca73d9..501e0c60 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -8,6 +8,7 @@ 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 cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; @@ -1436,6 +1437,7 @@ app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); app.use("/terminal", terminalRoutes); +app.use("/guacamole", guacamoleRoutes); app.use( ( diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts new file mode 100644 index 00000000..1d29c8f5 --- /dev/null +++ b/src/backend/guacamole/guacamole-server.ts @@ -0,0 +1,96 @@ +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 type { IncomingMessage } from "http"; + +const authManager = AuthManager.getInstance(); +const tokenService = GuacamoleTokenService.getInstance(); + +// Configuration from environment +const GUACD_HOST = process.env.GUACD_HOST || "localhost"; +const GUACD_PORT = parseInt(process.env.GUACD_PORT || "4822", 10); +const GUAC_WS_PORT = 30007; + +const websocketOptions = { + port: GUAC_WS_PORT, +}; + +const guacdOptions = { + host: GUACD_HOST, + port: GUACD_PORT, +}; + +const clientOptions = { + crypt: { + cypher: "AES-256-CBC", + key: tokenService.getEncryptionKey(), + }, + log: { + level: process.env.NODE_ENV === "production" ? "ERRORS" : "VERBOSE", + stdLog: (...args: unknown[]) => { + guacLogger.info(args.join(" "), { operation: "guac_log" }); + }, + errorLog: (...args: unknown[]) => { + guacLogger.error(args.join(" "), { operation: "guac_error" }); + }, + }, + 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", + }, + vnc: { + "swap-red-blue": false, + "cursor": "remote", + }, + telnet: { + "terminal-type": "xterm-256color", + }, + }, +}; + +// Create the guacamole-lite server +const guacServer = new GuacamoleLite( + websocketOptions, + guacdOptions, + clientOptions +); + +// Add authentication via processConnectionSettings callback +guacServer.on("open", (clientConnection: { connectionSettings?: Record }) => { + guacLogger.info("Guacamole connection opened", { + operation: "guac_connection_open", + type: clientConnection.connectionSettings?.type, + }); +}); + +guacServer.on("close", (clientConnection: { connectionSettings?: Record }) => { + guacLogger.info("Guacamole connection closed", { + operation: "guac_connection_close", + type: clientConnection.connectionSettings?.type, + }); +}); + +guacServer.on("error", (clientConnection: { connectionSettings?: Record }, error: Error) => { + guacLogger.error("Guacamole connection error", error, { + operation: "guac_connection_error", + type: clientConnection.connectionSettings?.type, + }); +}); + +guacLogger.info(`Guacamole WebSocket server started on port ${GUAC_WS_PORT}`, { + operation: "guac_server_start", + guacdHost: GUACD_HOST, + guacdPort: GUACD_PORT, +}); + +export { guacServer, tokenService }; + diff --git a/src/backend/guacamole/routes.ts b/src/backend/guacamole/routes.ts new file mode 100644 index 00000000..634cb661 --- /dev/null +++ b/src/backend/guacamole/routes.ts @@ -0,0 +1,141 @@ +import express from "express"; +import { GuacamoleTokenService } from "./token-service.js"; +import { guacLogger } from "../utils/logger.js"; +import { AuthManager } from "../utils/auth-manager.js"; +import type { AuthenticatedRequest } from "../../types/index.js"; + +const router = express.Router(); +const tokenService = GuacamoleTokenService.getInstance(); +const authManager = AuthManager.getInstance(); + +// Apply authentication middleware +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" }); + } + + guacLogger.info("Generated guacamole connection token", { + operation: "guac_token_generated", + userId, + type, + hostname, + }); + + 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" }); + } +}); + +/** + * GET /guacamole/status + * Check if guacd is reachable + */ +router.get("/status", async (req, res) => { + try { + const guacdHost = process.env.GUACD_HOST || "localhost"; + const guacdPort = parseInt(process.env.GUACD_PORT || "4822", 10); + + // Simple TCP check to see if guacd is responding + 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: 30007, + 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..ceafe8a0 --- /dev/null +++ b/src/backend/guacamole/token-service.ts @@ -0,0 +1,198 @@ +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; + // RDP specific + security?: string; + "ignore-cert"?: boolean; + "enable-wallpaper"?: boolean; + "enable-drive"?: boolean; + "drive-path"?: string; + "create-drive-path"?: boolean; + // VNC specific + "swap-red-blue"?: boolean; + cursor?: string; + // Telnet specific + "terminal-type"?: string; + [key: string]: unknown; + }; +} + +export interface GuacamoleToken { + connection: GuacamoleConnectionSettings; +} + +const CIPHER = "aes-256-cbc"; +const KEY_LENGTH = 32; // 256 bits = 32 bytes + +export class GuacamoleTokenService { + private static instance: GuacamoleTokenService; + private encryptionKey: Buffer; + + private constructor() { + // Use existing JWT secret or generate a dedicated key + this.encryptionKey = this.initializeKey(); + } + + static getInstance(): GuacamoleTokenService { + if (!GuacamoleTokenService.instance) { + GuacamoleTokenService.instance = new GuacamoleTokenService(); + } + return GuacamoleTokenService.instance; + } + + private initializeKey(): Buffer { + // Check for dedicated guacamole key first (must be 32 bytes / 64 hex chars) + const existingKey = process.env.GUACAMOLE_ENCRYPTION_KEY; + if (existingKey) { + // If it's hex encoded (64 chars = 32 bytes) + if (existingKey.length === 64 && /^[0-9a-fA-F]+$/.test(existingKey)) { + return Buffer.from(existingKey, "hex"); + } + // If it's already 32 bytes + if (existingKey.length === KEY_LENGTH) { + return Buffer.from(existingKey, "utf8"); + } + } + + // Generate a deterministic key from JWT_SECRET if available + const jwtSecret = process.env.JWT_SECRET; + if (jwtSecret) { + // SHA-256 produces exactly 32 bytes - perfect for AES-256 + return crypto.createHash("sha256").update(jwtSecret + "_guacamole").digest(); + } + + // Last resort: generate random key (note: won't persist across restarts) + guacLogger.warn("No persistent encryption key found, generating random key", { + operation: "guac_key_generation", + }); + return crypto.randomBytes(KEY_LENGTH); + } + + getEncryptionKey(): Buffer { + return this.encryptionKey; + } + + /** + * Encrypt connection settings into a token for guacamole-lite + */ + 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"); + } + + /** + * Decrypt a token (for verification/debugging purposes) + */ + 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; + } + } + + /** + * Create a connection token for RDP + * security options: "any", "nla", "nla-ext", "tls", "rdp", "vmconnect" + */ + createRdpToken( + hostname: string, + username: string, + password: string, + options: Partial = {} + ): string { + const token: GuacamoleToken = { + connection: { + type: "rdp", + settings: { + hostname, + username, + password, + port: 3389, + security: "nla", // NLA is required for modern Windows (10/11, Server 2016+) + "ignore-cert": true, + ...options, + }, + }, + }; + return this.encryptToken(token); + } + + /** + * Create a connection token for VNC + */ + createVncToken( + hostname: string, + password?: string, + options: Partial = {} + ): string { + const token: GuacamoleToken = { + connection: { + type: "vnc", + settings: { + hostname, + password, + port: 5900, + ...options, + }, + }, + }; + return this.encryptToken(token); + } + + /** + * Create a connection token for Telnet + */ + 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/starter.ts b/src/backend/starter.ts index b74c9b11..ae28f78b 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -104,6 +104,19 @@ import { systemLogger, versionLogger } from "./utils/logger.js"; await import("./ssh/server-stats.js"); await import("./dashboard.js"); + // Initialize Guacamole server for RDP/VNC/Telnet support + if (process.env.ENABLE_GUACAMOLE !== "false") { + 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", + }); + } + } + process.on("SIGINT", () => { systemLogger.info( "Received SIGINT signal, initiating graceful shutdown...", diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index 41f44982..eee456a0 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/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 836b95c0..9a8d313a 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -36,10 +36,12 @@ import { Loader2, Terminal, FolderOpen, + Monitor, } from "lucide-react"; import { Status } from "@/components/ui/shadcn-io/status"; import { BsLightning } from "react-icons/bs"; import { useTranslation } from "react-i18next"; +import { GuacamoleTestDialog } from "@/ui/desktop/apps/guacamole/GuacamoleTestDialog"; interface DashboardProps { onSelectView: (view: string) => void; @@ -687,6 +689,22 @@ export function Dashboard({ {t("dashboard.userProfile")} + + + + Test RDP/VNC + + + } + /> diff --git a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx new file mode 100644 index 00000000..cefbf9ac --- /dev/null +++ b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx @@ -0,0 +1,313 @@ +import { + useEffect, + useRef, + useState, + useImperativeHandle, + forwardRef, + useCallback, +} from "react"; +import Guacamole from "guacamole-common-js"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { getCookie, isElectron } from "@/ui/main-axios.ts"; +import { Loader2 } from "lucide-react"; + +export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet"; + +export interface GuacamoleConnectionConfig { + type: GuacamoleConnectionType; + hostname: string; + port?: number; + username?: string; + password?: string; + domain?: string; + // Display settings + width?: number; + height?: number; + dpi?: number; + // Additional protocol options + [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( + function GuacamoleDisplay( + { connectionConfig, isVisible, onConnect, onDisconnect, onError }, + ref + ) { + const { t } = useTranslation(); + const displayRef = useRef(null); + const clientRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [connectionError, setConnectionError] = useState(null); + + 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 (): Promise => { + const jwtToken = getCookie("jwt"); + if (!jwtToken) { + setConnectionError("Authentication required"); + return null; + } + + // First, get an encrypted token from the backend + try { + 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 { token } = await response.json(); + + // Build WebSocket URL + const wsBase = isDev + ? `ws://localhost:30007` + : 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)}`; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + setConnectionError(errorMessage); + onError?.(errorMessage); + return null; + } + }, [connectionConfig, onError]); + + const connect = useCallback(async () => { + if (isConnecting || isConnected) return; + setIsConnecting(true); + setConnectionError(null); + + const wsUrl = await getWebSocketUrl(); + if (!wsUrl) { + setIsConnecting(false); + return; + } + + const tunnel = new Guacamole.WebSocketTunnel(wsUrl); + const client = new Guacamole.Client(tunnel); + clientRef.current = client; + + // Set up display + const display = client.getDisplay(); + if (displayRef.current) { + displayRef.current.innerHTML = ""; + const displayElement = display.getElement(); + displayElement.style.width = "100%"; + displayElement.style.height = "100%"; + displayRef.current.appendChild(displayElement); + } + + // Handle display sync (when frames arrive) - scale to fit container + display.onresize = (width: number, height: number) => { + if (displayRef.current) { + const containerWidth = displayRef.current.clientWidth; + const containerHeight = displayRef.current.clientHeight; + const scale = Math.min(containerWidth / width, containerHeight / height); + display.scale(scale); + } + }; + + // Set up mouse input + const mouse = new Guacamole.Mouse(displayRef.current!); + mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: Guacamole.Mouse.State) => { + client.sendMouseState(state); + }; + + // Set up keyboard input + const keyboard = new Guacamole.Keyboard(document); + keyboard.onkeydown = (keysym: number) => { + client.sendKeyEvent(1, keysym); + }; + keyboard.onkeyup = (keysym: number) => { + client.sendKeyEvent(0, keysym); + }; + + // Handle client state changes + client.onstatechange = (state: number) => { + switch (state) { + case 0: // IDLE + break; + case 1: // CONNECTING + setIsConnecting(true); + break; + case 2: // WAITING + break; + case 3: // CONNECTED + setIsConnected(true); + setIsConnecting(false); + onConnect?.(); + break; + case 4: // DISCONNECTING + break; + case 5: // DISCONNECTED + setIsConnected(false); + setIsConnecting(false); + keyboard.onkeydown = null; + keyboard.onkeyup = null; + onDisconnect?.(); + break; + } + }; + + // Handle errors + client.onerror = (error: Guacamole.Status) => { + const errorMessage = error.message || "Connection error"; + setConnectionError(errorMessage); + setIsConnecting(false); + onError?.(errorMessage); + toast.error(`${t("guacamole.connectionError")}: ${errorMessage}`); + }; + + // Handle clipboard from remote + 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(() => {}); + }; + } + }; + + // Connect with display size + const width = connectionConfig.width || displayRef.current?.clientWidth || 1024; + const height = connectionConfig.height || displayRef.current?.clientHeight || 768; + const dpi = connectionConfig.dpi || 96; + + client.connect(`width=${width}&height=${height}&dpi=${dpi}`); + }, [isConnecting, isConnected, getWebSocketUrl, connectionConfig, onConnect, onDisconnect, onError, t]); + + // Track if we've initiated a connection to prevent re-triggering + const hasInitiatedRef = useRef(false); + + useEffect(() => { + if (isVisible && !hasInitiatedRef.current) { + hasInitiatedRef.current = true; + connect(); + } + }, [isVisible, connect]); + + // Separate cleanup effect that only runs on unmount + useEffect(() => { + return () => { + if (clientRef.current) { + clientRef.current.disconnect(); + } + }; + }, []); + + // Handle window resize + useEffect(() => { + const handleResize = () => { + if (clientRef.current && displayRef.current) { + const display = clientRef.current.getDisplay(); + const width = displayRef.current.clientWidth; + const height = displayRef.current.clientHeight; + display.scale(Math.min(width / display.getWidth(), height / display.getHeight())); + } + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return ( +
+
+ + {isConnecting && ( +
+
+ + + {t("guacamole.connecting", { type: connectionConfig.type.toUpperCase() })} + +
+
+ )} + + {connectionError && !isConnecting && ( +
+
+ {t("guacamole.connectionFailed")} + {connectionError} +
+
+ )} +
+ ); + } +); + diff --git a/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx b/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx new file mode 100644 index 00000000..913428f0 --- /dev/null +++ b/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Monitor, MonitorPlay, Terminal } from "lucide-react"; +import { GuacamoleDisplay, GuacamoleConnectionConfig } from "./GuacamoleDisplay"; + +interface GuacamoleTestDialogProps { + trigger?: React.ReactNode; +} + +export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [connectionConfig, setConnectionConfig] = useState(null); + + const [connectionType, setConnectionType] = useState<"rdp" | "vnc" | "telnet">("rdp"); + const [hostname, setHostname] = useState(""); + const [port, setPort] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [domain, setDomain] = useState(""); + const [security, setSecurity] = useState("nla"); + + const defaultPorts = { rdp: "3389", vnc: "5900", telnet: "23" }; + + const handleConnect = () => { + if (!hostname) return; + + const config: GuacamoleConnectionConfig = { + type: connectionType, + hostname, + port: parseInt(port || defaultPorts[connectionType]), + username: username || undefined, + password: password || undefined, + domain: domain || undefined, + security: connectionType === "rdp" ? security : undefined, + "ignore-cert": true, + }; + + setConnectionConfig(config); + setIsConnecting(true); + }; + + const handleDisconnect = () => { + setConnectionConfig(null); + setIsConnecting(false); + }; + + const handleClose = () => { + handleDisconnect(); + setIsOpen(false); + }; + + return ( + open ? setIsOpen(true) : handleClose()}> + + {trigger || ( + + )} + + + + + + {isConnecting ? `Connected to ${hostname}` : "Test Remote Connection"} + + + + {!isConnecting ? ( +
+ { + setConnectionType(v as "rdp" | "vnc" | "telnet"); + setPort(""); + }}> + + + RDP + + + VNC + + + Telnet + + + + +
+
+ + setHostname(e.target.value)} placeholder="192.168.1.100" /> +
+
+ + setPort(e.target.value)} placeholder="3389" /> +
+
+
+
+ + setDomain(e.target.value)} placeholder="WORKGROUP" /> +
+
+ + +
+
+
+
+ + setUsername(e.target.value)} placeholder="Administrator" /> +
+
+ + setPassword(e.target.value)} /> +
+
+
+ + +
+
+ + setHostname(e.target.value)} placeholder="192.168.1.100" /> +
+
+ + setPort(e.target.value)} placeholder="5900" /> +
+
+
+ + setPassword(e.target.value)} /> +
+
+ + +
+
+ + setHostname(e.target.value)} placeholder="192.168.1.100" /> +
+
+ + setPort(e.target.value)} placeholder="23" /> +
+
+
+
+ + +
+ ) : ( +
+ console.error("Guacamole error:", err)} + /> +
+ )} +
+
+ ); +} + From 2f092bd367f887e405edc4131a0f32cdd8d90761 Mon Sep 17 00:00:00 2001 From: starhound Date: Wed, 17 Dec 2025 19:14:19 -0500 Subject: [PATCH 2/8] feat: enhance Guacamole support with RDP and VNC connection settings and UI updates --- src/backend/guacamole/guacamole-server.ts | 12 +++ src/types/index.ts | 17 ++- src/ui/desktop/DesktopApp.tsx | 4 +- .../apps/guacamole/GuacamoleDisplay.tsx | 100 ++++++++++++------ .../apps/guacamole/GuacamoleTestDialog.tsx | 43 +++----- src/ui/desktop/navigation/AppView.tsx | 20 +++- src/ui/desktop/navigation/tabs/Tab.tsx | 12 ++- 7 files changed, 145 insertions(+), 63 deletions(-) diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts index 1d29c8f5..92100b1d 100644 --- a/src/backend/guacamole/guacamole-server.ts +++ b/src/backend/guacamole/guacamole-server.ts @@ -36,6 +36,13 @@ const clientOptions = { guacLogger.error(args.join(" "), { operation: "guac_error" }); }, }, + // Allow width, height, and dpi to be passed as query parameters + // This allows the client to request the appropriate resolution at connection time + allowedUnencryptedConnectionSettings: { + rdp: ["width", "height", "dpi"], + vnc: ["width", "height", "dpi"], + telnet: ["width", "height"], + }, connectionDefaultSettings: { rdp: { security: "any", @@ -46,10 +53,15 @@ const clientOptions = { "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", diff --git a/src/types/index.ts b/src/types/index.ts index 83dad26a..3e4695c2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -345,11 +345,26 @@ export interface TabContextTab { | "server" | "admin" | "file_manager" - | "user_profile"; + | "user_profile" + | "rdp" + | "vnc"; title: string; hostConfig?: SSHHost; terminalRef?: any; initialTab?: string; + connectionConfig?: { + type: "rdp" | "vnc" | "telnet"; + hostname: string; + port: number; + username?: string; + password?: string; + domain?: string; + security?: string; + "ignore-cert"?: boolean; + width?: number; + height?: number; + dpi?: number; + }; } export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index fb015997..7f47bfff 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -155,7 +155,9 @@ function AppContent() { const showTerminalView = currentTabData?.type === "terminal" || currentTabData?.type === "server" || - currentTabData?.type === "file_manager"; + currentTabData?.type === "file_manager" || + currentTabData?.type === "rdp" || + currentTabData?.type === "vnc"; const showHome = currentTabData?.type === "home"; const showSshManager = currentTabData?.type === "ssh_manager"; const showAdmin = currentTabData?.type === "admin"; diff --git a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx index cefbf9ac..34b3cc7c 100644 --- a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx @@ -52,7 +52,8 @@ export const GuacamoleDisplay = forwardRef(null); + const containerRef = useRef(null); // Outer container for measuring size + const displayRef = useRef(null); // Inner div for guacamole canvas const clientRef = useRef(null); const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); @@ -86,7 +87,7 @@ export const GuacamoleDisplay = forwardRef => { + const getWebSocketUrl = useCallback(async (containerWidth: number, containerHeight: number): Promise => { const jwtToken = getCookie("jwt"); if (!jwtToken) { setConnectionError("Authentication required"); @@ -118,7 +119,13 @@ export const GuacamoleDisplay = forwardRef { - if (displayRef.current) { - const containerWidth = displayRef.current.clientWidth; - const containerHeight = displayRef.current.clientHeight; - const scale = Math.min(containerWidth / width, containerHeight / height); + // Function to rescale display to fit container + const rescaleDisplay = () => { + if (!containerRef.current) return; + + 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); display.scale(scale); } }; - // Set up mouse input - const mouse = new Guacamole.Mouse(displayRef.current!); + // Handle display sync (when frames arrive) + display.onresize = () => { + rescaleDisplay(); + }; + + // Set up mouse input on the display element (not the container) + const mouse = new Guacamole.Mouse(displayElement); mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = (state: Guacamole.Mouse.State) => { client.sendMouseState(state); }; @@ -237,12 +267,8 @@ export const GuacamoleDisplay = forwardRef { const handleResize = () => { - if (clientRef.current && displayRef.current) { + if (clientRef.current && containerRef.current) { const display = clientRef.current.getDisplay(); - const width = displayRef.current.clientWidth; - const height = displayRef.current.clientHeight; - display.scale(Math.min(width / display.getWidth(), height / display.getHeight())); + 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); + display.scale(scale); + } } }; window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); + // Also trigger on initial render after a short delay + const initialTimeout = setTimeout(handleResize, 100); + return () => { + window.removeEventListener("resize", handleResize); + clearTimeout(initialTimeout); + }; }, []); return ( -
+
diff --git a/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx b/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx index 913428f0..75b27373 100644 --- a/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx +++ b/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx @@ -13,7 +13,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { PasswordInput } from "@/components/ui/password-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Monitor, MonitorPlay, Terminal } from "lucide-react"; -import { GuacamoleDisplay, GuacamoleConnectionConfig } from "./GuacamoleDisplay"; +import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; +import type { GuacamoleConnectionConfig } from "./GuacamoleDisplay"; interface GuacamoleTestDialogProps { trigger?: React.ReactNode; @@ -21,8 +22,7 @@ interface GuacamoleTestDialogProps { export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) { const [isOpen, setIsOpen] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [connectionConfig, setConnectionConfig] = useState(null); + const { addTab } = useTabs(); const [connectionType, setConnectionType] = useState<"rdp" | "vnc" | "telnet">("rdp"); const [hostname, setHostname] = useState(""); @@ -48,22 +48,22 @@ export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) { "ignore-cert": true, }; - setConnectionConfig(config); - setIsConnecting(true); - }; + // Add a new tab for the remote desktop connection + const tabType = connectionType === "rdp" ? "rdp" : connectionType === "vnc" ? "vnc" : "rdp"; + const title = `${connectionType.toUpperCase()} - ${hostname}`; - const handleDisconnect = () => { - setConnectionConfig(null); - setIsConnecting(false); - }; + addTab({ + type: tabType, + title, + connectionConfig: config, + }); - const handleClose = () => { - handleDisconnect(); + // Close the dialog setIsOpen(false); }; return ( - open ? setIsOpen(true) : handleClose()}> + {trigger || ( )} - + - {isConnecting ? `Connected to ${hostname}` : "Test Remote Connection"} + Remote Connection - {!isConnecting ? ( -
+
{ setConnectionType(v as "rdp" | "vnc" | "telnet"); setPort(""); @@ -177,16 +176,6 @@ export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) { Connect
- ) : ( -
- console.error("Guacamole error:", err)} - /> -
- )}
); diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx index 51baf6e4..9eeb054b 100644 --- a/src/ui/desktop/navigation/AppView.tsx +++ b/src/ui/desktop/navigation/AppView.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from "react"; import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx"; import { Server as ServerView } from "@/ui/desktop/apps/server/Server.tsx"; import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx"; +import { GuacamoleDisplay, type GuacamoleConnectionConfig } from "@/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { ResizablePanelGroup, @@ -16,7 +17,6 @@ import { TERMINAL_THEMES, DEFAULT_TERMINAL_CONFIG, } from "@/constants/terminal-themes"; -import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; interface TabData { id: number; @@ -30,6 +30,7 @@ interface TabData { }; }; hostConfig?: any; + connectionConfig?: GuacamoleConnectionConfig; [key: string]: unknown; } @@ -58,7 +59,9 @@ export function AppView({ (tab: TabData) => tab.type === "terminal" || tab.type === "server" || - tab.type === "file_manager", + tab.type === "file_manager" || + tab.type === "rdp" || + tab.type === "vnc", ), [tabs], ); @@ -317,6 +320,19 @@ export function AppView({ isTopbarOpen={isTopbarOpen} embedded /> + ) : t.type === "rdp" || t.type === "vnc" ? ( + t.connectionConfig ? ( + removeTab(t.id)} + onError={(err) => console.error("Guacamole error:", err)} + /> + ) : ( +
+ Missing connection configuration +
+ ) ) : ( ) : isUserProfile ? ( + ) : isRemoteDesktop ? ( + ) : ( )} From d047beab13b9d67f896e71069bcbf62a07013cc7 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 18 Dec 2025 13:58:02 -0600 Subject: [PATCH 3/8] fix: merge syntax errors --- src/types/index.ts | 21 +++++++++++---------- src/ui/desktop/DesktopApp.tsx | 2 +- src/ui/desktop/navigation/AppView.tsx | 7 +++++-- src/ui/desktop/navigation/tabs/Tab.tsx | 11 ++++------- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index cb2893ac..43d1b525 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -354,16 +354,17 @@ export interface TerminalConfig { export interface TabContextTab { id: number; type: - | "home" - | "terminal" - | "ssh_manager" - | "server" - | "admin" - | "file_manager" - | "user_profile" - | "rdp" - | "vnc"; - | "docker"; + | "home" + | "terminal" + | "ssh_manager" + | "server" + | "admin" + | "file_manager" + | "user_profile" + | "rdp" + | "vnc" + | "tunnel" + | "docker"; title: string; hostConfig?: SSHHost; terminalRef?: any; diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index b2552817..d5c0ee0c 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -157,7 +157,7 @@ function AppContent() { currentTabData?.type === "server" || currentTabData?.type === "file_manager" || currentTabData?.type === "rdp" || - currentTabData?.type === "vnc"; + currentTabData?.type === "vnc" || currentTabData?.type === "tunnel" || currentTabData?.type === "docker"; const showHome = currentTabData?.type === "home"; diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx index aa08a892..d6c65895 100644 --- a/src/ui/desktop/navigation/AppView.tsx +++ b/src/ui/desktop/navigation/AppView.tsx @@ -2,7 +2,10 @@ import React, { useEffect, useRef, useState, useMemo } from "react"; import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx"; import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx"; import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx"; -import { GuacamoleDisplay, type GuacamoleConnectionConfig } from "@/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx"; +import { + GuacamoleDisplay, + type GuacamoleConnectionConfig, +} from "@/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx"; import { TunnelManager } from "@/ui/desktop/apps/tunnel/TunnelManager.tsx"; import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; @@ -63,7 +66,7 @@ export function AppView({ tab.type === "server" || tab.type === "file_manager" || tab.type === "rdp" || - tab.type === "vnc", || + tab.type === "vnc" || tab.type === "tunnel" || tab.type === "docker", ), diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx index c0467711..5d76068f 100644 --- a/src/ui/desktop/navigation/tabs/Tab.tsx +++ b/src/ui/desktop/navigation/tabs/Tab.tsx @@ -124,7 +124,7 @@ export function Tab({ tabType === "file_manager" || tabType === "user_profile" || tabType === "rdp" || - tabType === "vnc" + tabType === "vnc" || tabType === "tunnel" || tabType === "docker" ) { @@ -141,18 +141,15 @@ export function Tab({ ? t("nav.serverStats") : isFileManager ? t("nav.fileManager") - : isUserProfile - ? t("nav.userProfile") - : isRemoteDesktop - ? tabType.toUpperCase() - : t("nav.terminal")); : isTunnel ? t("nav.tunnels") : isDocker ? t("nav.docker") : isUserProfile ? t("nav.userProfile") - : t("nav.terminal")); + : isRemoteDesktop + ? tabType.toUpperCase() + : t("nav.terminal")); const { base, suffix } = splitTitle(displayTitle); From 5d61112a4e9f6cd5c65123b266946f3fe7093426 Mon Sep 17 00:00:00 2001 From: starhound Date: Fri, 19 Dec 2025 03:55:46 -0500 Subject: [PATCH 4/8] feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay --- .../apps/guacamole/GuacamoleDisplay.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx index 34b3cc7c..06955ae6 100644 --- a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx @@ -55,6 +55,7 @@ export const GuacamoleDisplay = forwardRef(null); // Outer container for measuring size const displayRef = useRef(null); // Inner div for guacamole canvas const clientRef = useRef(null); + const scaleRef = useRef(1); // Track current scale factor for mouse const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [connectionError, setConnectionError] = useState(null); @@ -193,6 +194,7 @@ export const GuacamoleDisplay = forwardRef 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) { const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight); + scaleRef.current = scale; display.scale(scale); } }; @@ -203,10 +205,28 @@ export const GuacamoleDisplay = forwardRef { - client.sendMouseState(state); + const sendMouseState = (state: Guacamole.Mouse.State) => { + // Adjust coordinates based on scale factor and round to integers + const scale = scaleRef.current; + const adjustedX = Math.round(state.x / scale); + const adjustedY = Math.round(state.y / scale); + + // Create adjusted state - guacamole expects integer coordinates + 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; // Set up keyboard input const keyboard = new Guacamole.Keyboard(document); @@ -302,6 +322,7 @@ export const GuacamoleDisplay = forwardRef 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) { const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight); + scaleRef.current = scale; display.scale(scale); } } From bc6264bb50014d55c627a40ebca825638f97c4be Mon Sep 17 00:00:00 2001 From: starhound Date: Fri, 19 Dec 2025 03:58:26 -0500 Subject: [PATCH 5/8] feat: add TypeScript definitions for guacamole-common-js module --- src/types/guacamole-common-js.d.ts | 100 +++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/types/guacamole-common-js.d.ts diff --git a/src/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts new file mode 100644 index 00000000..a377e0f9 --- /dev/null +++ b/src/types/guacamole-common-js.d.ts @@ -0,0 +1,100 @@ +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 + ); + 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; +} + From 3ac7ad0bd78b0486ed6207c79ab93f830bfede38 Mon Sep 17 00:00:00 2001 From: starhound Date: Fri, 19 Dec 2025 04:03:20 -0500 Subject: [PATCH 6/8] feat: enhance Mouse.State constructor to accept optional parameters and object destructuring --- src/types/guacamole-common-js.d.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts index a377e0f9..349d8a65 100644 --- a/src/types/guacamole-common-js.d.ts +++ b/src/types/guacamole-common-js.d.ts @@ -44,12 +44,21 @@ declare module "guacamole-common-js" { constructor( x: number, y: number, - left: boolean, - middle: boolean, - right: boolean, - up: boolean, - down: boolean + 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; From 776f5813777bebc8680eadbdba2617f1e2701bdf Mon Sep 17 00:00:00 2001 From: starhound Date: Fri, 19 Dec 2025 16:08:27 -0500 Subject: [PATCH 7/8] feat: Add support for RDP and VNC connections in SSH host management - Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures. - Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert. - Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options. - Implemented token retrieval for RDP/VNC connections using Guacamole API. - Updated UI components to reflect connection type changes and provide appropriate connection buttons. - Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor. - Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions. --- src/backend/database/db/index.ts | 6 + src/backend/database/db/schema.ts | 6 + src/backend/database/routes/ssh.ts | 21 ++ src/types/index.ts | 20 +- src/ui/desktop/apps/dashboard/Dashboard.tsx | 18 -- .../apps/guacamole/GuacamoleDisplay.tsx | 74 +++-- .../apps/guacamole/GuacamoleTestDialog.tsx | 183 ----------- .../apps/host-manager/HostManagerEditor.tsx | 290 ++++++++++++++---- .../apps/host-manager/HostManagerViewer.tsx | 80 ++++- src/ui/desktop/navigation/TopNavbar.tsx | 10 +- src/ui/desktop/navigation/hosts/Host.tsx | 142 ++++++--- src/ui/main-axios.ts | 47 +++ 12 files changed, 542 insertions(+), 355 deletions(-) delete mode 100644 src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index ee65b059..ddc79413 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -495,6 +495,12 @@ const migrateSchema = () => { ); addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); + // Connection type columns for RDP/VNC/Telnet support + 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_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 2bf276aa..51f6f167 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -52,6 +52,8 @@ export const sshData = sqliteTable("ssh_data", { userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), + // Connection type: ssh, rdp, vnc, telnet + connectionType: text("connection_type").notNull().default("ssh"), name: text("name"), ip: text("ip").notNull(), port: integer("port").notNull(), @@ -94,6 +96,10 @@ export const sshData = sqliteTable("ssh_data", { dockerConfig: text("docker_config"), terminalConfig: text("terminal_config"), quickActions: text("quick_actions"), + // RDP/VNC specific fields + domain: text("domain"), + security: text("security"), + ignoreCert: integer("ignore_cert", { mode: "boolean" }).default(false), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 6cde8e29..a341ea72 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -218,6 +218,7 @@ router.post( } const { + connectionType, name, folder, tags, @@ -244,6 +245,10 @@ router.post( dockerConfig, terminalConfig, forceKeyboardInteractive, + // RDP/VNC specific fields + domain, + security, + ignoreCert, } = hostData; if ( !isNonEmptyString(userId) || @@ -261,8 +266,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 || "", @@ -288,6 +295,10 @@ router.post( dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", + // RDP/VNC specific fields + domain: domain || null, + security: security || null, + ignoreCert: ignoreCert ? 1 : 0, }; if (effectiveAuthType === "password") { @@ -448,6 +459,7 @@ router.put( } const { + connectionType, name, folder, tags, @@ -474,6 +486,10 @@ router.put( dockerConfig, terminalConfig, forceKeyboardInteractive, + // RDP/VNC specific fields + domain, + security, + ignoreCert, } = hostData; if ( !isNonEmptyString(userId) || @@ -494,6 +510,7 @@ router.put( const effectiveAuthType = authType || authMethod; const sshDataObj: Record = { + connectionType: connectionType || "ssh", name, folder, tags: Array.isArray(tags) ? tags.join(",") : tags || "", @@ -519,6 +536,10 @@ router.put( dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", + // RDP/VNC specific fields + domain: domain || null, + security: security || null, + ignoreCert: ignoreCert ? 1 : 0, }; if (effectiveAuthType === "password") { diff --git a/src/types/index.ts b/src/types/index.ts index 43d1b525..4b7fe408 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,8 +25,11 @@ export interface DockerConfig { tlsKey?: string; } +export type HostConnectionType = "ssh" | "rdp" | "vnc" | "telnet"; + export interface SSHHost { id: number; + connectionType: HostConnectionType; name: string; ip: string; port: number; @@ -59,6 +62,10 @@ export interface SSHHost { statsConfig?: string; dockerConfig?: string; terminalConfig?: TerminalConfig; + // RDP/VNC specific fields + domain?: string; + security?: string; + ignoreCert?: boolean; createdAt: string; updatedAt: string; } @@ -73,6 +80,7 @@ export interface QuickActionData { } export interface SSHHostData { + connectionType?: HostConnectionType; name?: string; ip: string; port: number; @@ -99,6 +107,10 @@ export interface SSHHostData { statsConfig?: string | Record; dockerConfig?: DockerConfig | string; terminalConfig?: TerminalConfig; + // RDP/VNC specific fields + domain?: string; + security?: string; + ignoreCert?: boolean; } export interface SSHFolder { @@ -370,9 +382,11 @@ export interface TabContextTab { terminalRef?: any; initialTab?: string; connectionConfig?: { - type: "rdp" | "vnc" | "telnet"; - hostname: string; - port: number; + token: string; + protocol: "rdp" | "vnc" | "telnet"; + type?: "rdp" | "vnc" | "telnet"; + hostname?: string; + port?: number; username?: string; password?: string; domain?: string; diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 9a8d313a..836b95c0 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -36,12 +36,10 @@ import { Loader2, Terminal, FolderOpen, - Monitor, } from "lucide-react"; import { Status } from "@/components/ui/shadcn-io/status"; import { BsLightning } from "react-icons/bs"; import { useTranslation } from "react-i18next"; -import { GuacamoleTestDialog } from "@/ui/desktop/apps/guacamole/GuacamoleTestDialog"; interface DashboardProps { onSelectView: (view: string) => void; @@ -689,22 +687,6 @@ export function Dashboard({ {t("dashboard.userProfile")} - - - - Test RDP/VNC - - - } - />
diff --git a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx index 06955ae6..0780bcc2 100644 --- a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx +++ b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx @@ -15,8 +15,12 @@ import { Loader2 } from "lucide-react"; export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet"; export interface GuacamoleConnectionConfig { - type: GuacamoleConnectionType; - hostname: string; + // Pre-fetched token (preferred) - if provided, skip token fetch + token?: string; + protocol?: GuacamoleConnectionType; + // Legacy fields for backward compatibility (used if token not provided) + type?: GuacamoleConnectionType; + hostname?: string; port?: number; username?: string; password?: string; @@ -89,36 +93,44 @@ export const GuacamoleDisplay = forwardRef => { - const jwtToken = getCookie("jwt"); - if (!jwtToken) { - setConnectionError("Authentication required"); - return null; - } - - // First, get an encrypted token from the backend try { - 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"); - } + let token: string; + + // If token is pre-fetched, use it directly + if (connectionConfig.token) { + token = connectionConfig.token; + } else { + // Otherwise, fetch token from backend (legacy behavior) + const jwtToken = getCookie("jwt"); + if (!jwtToken) { + setConnectionError("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 { token } = await response.json(); + const data = await response.json(); + token = data.token; + } // Build WebSocket URL with width/height/dpi as query parameters // These are passed as unencrypted settings to guacamole-lite @@ -353,7 +365,7 @@ export const GuacamoleDisplay = forwardRef - {t("guacamole.connecting", { type: connectionConfig.type.toUpperCase() })} + {t("guacamole.connecting", { type: (connectionConfig.protocol || connectionConfig.type || "remote").toUpperCase() })}
diff --git a/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx b/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx deleted file mode 100644 index 75b27373..00000000 --- a/src/ui/desktop/apps/guacamole/GuacamoleTestDialog.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { useState } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { PasswordInput } from "@/components/ui/password-input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Monitor, MonitorPlay, Terminal } from "lucide-react"; -import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; -import type { GuacamoleConnectionConfig } from "./GuacamoleDisplay"; - -interface GuacamoleTestDialogProps { - trigger?: React.ReactNode; -} - -export function GuacamoleTestDialog({ trigger }: GuacamoleTestDialogProps) { - const [isOpen, setIsOpen] = useState(false); - const { addTab } = useTabs(); - - const [connectionType, setConnectionType] = useState<"rdp" | "vnc" | "telnet">("rdp"); - const [hostname, setHostname] = useState(""); - const [port, setPort] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [domain, setDomain] = useState(""); - const [security, setSecurity] = useState("nla"); - - const defaultPorts = { rdp: "3389", vnc: "5900", telnet: "23" }; - - const handleConnect = () => { - if (!hostname) return; - - const config: GuacamoleConnectionConfig = { - type: connectionType, - hostname, - port: parseInt(port || defaultPorts[connectionType]), - username: username || undefined, - password: password || undefined, - domain: domain || undefined, - security: connectionType === "rdp" ? security : undefined, - "ignore-cert": true, - }; - - // Add a new tab for the remote desktop connection - const tabType = connectionType === "rdp" ? "rdp" : connectionType === "vnc" ? "vnc" : "rdp"; - const title = `${connectionType.toUpperCase()} - ${hostname}`; - - addTab({ - type: tabType, - title, - connectionConfig: config, - }); - - // Close the dialog - setIsOpen(false); - }; - - return ( - - - {trigger || ( - - )} - - - - - - Remote Connection - - - -
- { - setConnectionType(v as "rdp" | "vnc" | "telnet"); - setPort(""); - }}> - - - RDP - - - VNC - - - Telnet - - - - -
-
- - setHostname(e.target.value)} placeholder="192.168.1.100" /> -
-
- - setPort(e.target.value)} placeholder="3389" /> -
-
-
-
- - setDomain(e.target.value)} placeholder="WORKGROUP" /> -
-
- - -
-
-
-
- - setUsername(e.target.value)} placeholder="Administrator" /> -
-
- - setPassword(e.target.value)} /> -
-
-
- - -
-
- - setHostname(e.target.value)} placeholder="192.168.1.100" /> -
-
- - setPort(e.target.value)} placeholder="5900" /> -
-
-
- - setPassword(e.target.value)} /> -
-
- - -
-
- - setHostname(e.target.value)} placeholder="192.168.1.100" /> -
-
- - setPort(e.target.value)} placeholder="23" /> -
-
-
-
- - -
-
-
- ); -} - diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index e0041c1a..9e61c682 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -435,6 +435,7 @@ 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), port: z.coerce.number().min(1).max(65535), @@ -443,6 +444,10 @@ export function HostManagerEditor({ tags: z.array(z.string().min(1)).default([]), pin: z.boolean().default(false), authType: z.enum(["password", "key", "credential", "none"]), + // RDP/VNC specific fields + domain: z.string().optional(), + security: z.string().optional(), + ignoreCert: z.boolean().default(false), credentialId: z.number().optional().nullable(), overrideCredentialUsername: z.boolean().optional(), password: z.string().optional(), @@ -648,6 +653,7 @@ export function HostManagerEditor({ resolver: zodResolver(formSchema) as any, defaultValues: { name: "", + connectionType: "ssh" as const, ip: "", port: 22, username: "", @@ -682,6 +688,10 @@ export function HostManagerEditor({ tlsCert: "", tlsKey: "", }, + // RDP/VNC specific defaults + domain: "", + security: "", + ignoreCert: false, }, }); @@ -759,6 +769,7 @@ export function HostManagerEditor({ } const formData = { + connectionType: (cleanedHost.connectionType || "ssh") as "ssh" | "rdp" | "vnc" | "telnet", name: cleanedHost.name || "", ip: cleanedHost.ip || "", port: cleanedHost.port || 22, @@ -801,6 +812,10 @@ export function HostManagerEditor({ forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), enableDocker: Boolean(cleanedHost.enableDocker), dockerConfig: parsedDockerConfig, + // RDP/VNC specific fields + domain: cleanedHost.domain || "", + security: cleanedHost.security || "", + ignoreCert: Boolean(cleanedHost.ignoreCert), }; if (defaultAuthType === "password") { @@ -828,6 +843,7 @@ export function HostManagerEditor({ } else { setAuthTab("password"); const defaultFormData = { + connectionType: "ssh" as const, name: "", ip: "", port: 22, @@ -863,6 +879,10 @@ export function HostManagerEditor({ tlsCert: "", tlsKey: "", }, + // RDP/VNC specific defaults + domain: "", + security: "", + ignoreCert: false, }; form.reset(defaultFormData); @@ -910,6 +930,7 @@ export function HostManagerEditor({ } const submitData: Record = { + connectionType: data.connectionType || "ssh", name: data.name, ip: data.ip, port: data.port, @@ -931,6 +952,10 @@ export function HostManagerEditor({ statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG, terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive), + // RDP/VNC specific fields + domain: data.domain || null, + security: data.security || null, + ignoreCert: Boolean(data.ignoreCert), }; submitData.credentialId = null; @@ -1230,23 +1255,69 @@ export function HostManagerEditor({ onValueChange={setActiveTab} className="w-full" > - - - {t("hosts.general")} - - - {t("hosts.terminal")} - - Docker - {t("hosts.tunnel")} - - {t("hosts.fileManager")} - - - {t("hosts.statistics")} - - + {/* Only show tabs if there's more than just the General tab (SSH has extra tabs) */} + {form.watch("connectionType") === "ssh" && ( + + + {t("hosts.general")} + + + {t("hosts.terminal")} + + Docker + {t("hosts.tunnel")} + + {t("hosts.fileManager")} + + + {t("hosts.statistics")} + + + )} + + {t("hosts.connectionType", "Connection Type")} + +
+ ( + + +
+ {[ + { value: "ssh", label: "SSH" }, + { value: "rdp", label: "RDP" }, + { value: "vnc", label: "VNC" }, + { value: "telnet", label: "Telnet" }, + ].map((option) => ( + + ))} +
+
+
+ )} + /> +
{t("hosts.connectionDetails")} @@ -1314,6 +1385,75 @@ export function HostManagerEditor({ }} /> + {/* RDP-specific fields */} + {form.watch("connectionType") === "rdp" && ( + <> + + {t("hosts.rdpSettings", "RDP Settings")} + +
+ ( + + {t("hosts.domain", "Domain")} + + + + + )} + /> + ( + + {t("hosts.security", "Security")} + + + )} + /> + ( + +
+ {t("hosts.ignoreCert", "Ignore Certificate")} +
+ + + +
+ )} + /> +
+ + )} {t("hosts.organization")} @@ -1456,49 +1596,54 @@ export function HostManagerEditor({ )} /> - - {t("hosts.authentication")} - - { - const newAuthType = value as - | "password" - | "key" - | "credential" - | "none"; - 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.password")} - - - - - )} - /> - + {/* Authentication section - only for SSH and Telnet */} + {(form.watch("connectionType") === "ssh" || form.watch("connectionType") === "telnet") && ( + <> + + {t("hosts.authentication")} + + { + const newAuthType = value as + | "password" + | "key" + | "credential" + | "none"; + setAuthTab(newAuthType); + form.setValue("authType", newAuthType); + }} + className="flex-1 flex flex-col h-full min-h-0" + > + + + {t("hosts.password")} + + {form.watch("connectionType") === "ssh" && ( + {t("hosts.key")} + )} + + {t("hosts.credential")} + + {t("hosts.none")} + + + ( + + {t("hosts.password")} + + + + + )} + /> + + + )} + {/* RDP/VNC password authentication - simpler than SSH */} + {(form.watch("connectionType") === "rdp" || form.watch("connectionType") === "vnc") && ( + <> + + {t("hosts.authentication")} + +
+ ( + + {t("hosts.password")} + + + + + )} + /> +
+ + )}
- {host.enableTerminal && ( + {/* Show connection type badge */} + {(host.connectionType === "rdp" || host.connectionType === "vnc") ? ( + + {host.connectionType === "rdp" ? ( + + ) : ( + + )} + {host.connectionType.toUpperCase()} + + ) : host.connectionType === "telnet" ? ( + + + Telnet + + ) : host.enableTerminal && (
- {host.enableTerminal && ( + {/* Show connect button for SSH/Telnet if enableTerminal, or always for RDP/VNC */} + {(host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") && ( -

Open Terminal

+

{host.connectionType === "rdp" ? "Open RDP" : host.connectionType === "vnc" ? "Open VNC" : "Open Terminal"}

)} diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 4838726c..10185444 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -374,6 +374,8 @@ export function TopNavbar({ const isSshManager = tab.type === "ssh_manager"; const isAdmin = tab.type === "admin"; const isUserProfile = tab.type === "user_profile"; + const isRdp = tab.type === "rdp"; + const isVnc = tab.type === "vnc"; const isSplittable = isTerminal || isServer || isFileManager || isTunnel || isDocker; const disableSplit = !isSplittable; @@ -491,7 +493,9 @@ export function TopNavbar({ isDocker || isSshManager || isAdmin || - isUserProfile + isUserProfile || + isRdp || + isVnc ? () => handleTabClose(tab.id) : undefined } @@ -507,7 +511,9 @@ export function TopNavbar({ isDocker || isSshManager || isAdmin || - isUserProfile + isUserProfile || + isRdp || + isVnc } disableActivate={disableActivate} disableSplit={disableSplit} diff --git a/src/ui/desktop/navigation/hosts/Host.tsx b/src/ui/desktop/navigation/hosts/Host.tsx index f085f641..05da70d6 100644 --- a/src/ui/desktop/navigation/hosts/Host.tsx +++ b/src/ui/desktop/navigation/hosts/Host.tsx @@ -10,6 +10,8 @@ import { Pencil, ArrowDownUp, Container, + Monitor, + ScreenShare, } from "lucide-react"; import { DropdownMenu, @@ -18,7 +20,7 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; -import { getServerStatusById } from "@/ui/main-axios"; +import { getServerStatusById, getGuacamoleToken } from "@/ui/main-axios"; import type { HostProps } from "../../../../types"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; @@ -106,8 +108,38 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement { }; }, [host.id, shouldShowStatus]); - const handleTerminalClick = () => { - addTab({ type: "terminal", title, hostConfig: host }); + const handleTerminalClick = async () => { + const connectionType = host.connectionType || "ssh"; + + if (connectionType === "ssh" || connectionType === "telnet") { + addTab({ type: "terminal", title, hostConfig: host }); + } else if (connectionType === "rdp" || connectionType === "vnc") { + try { + // Get guacamole token for RDP/VNC connection + const tokenResponse = await getGuacamoleToken({ + protocol: connectionType, + hostname: host.ip, + port: host.port, + username: host.username, + password: host.password || "", + domain: host.domain, + security: host.security, + ignoreCert: host.ignoreCert, + }); + + addTab({ + type: connectionType, + title, + hostConfig: host, + connectionConfig: { + token: tokenResponse.token, + protocol: connectionType, + }, + }); + } catch (error) { + console.error(`Failed to get guacamole token for ${connectionType}:`, error); + } + } }; return ( @@ -127,13 +159,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {

- {host.enableTerminal && ( + {/* Show connect button for SSH/Telnet if enableTerminal, or always for RDP/VNC */} + {(host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") && ( )} @@ -142,7 +181,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
+ + {/* RDP/VNC Display Tab */} + + + {t("hosts.displaySettings", "Display Settings")} + + +
+ ( + + {t("hosts.width", "Width")} + + field.onChange(e.target.value ? parseInt(e.target.value) : undefined)} + /> + + + {t("hosts.widthDesc", "Display width in pixels (leave empty for auto)")} + + + )} + /> + + ( + + {t("hosts.height", "Height")} + + field.onChange(e.target.value ? parseInt(e.target.value) : undefined)} + /> + + + {t("hosts.heightDesc", "Display height in pixels (leave empty for auto)")} + + + )} + /> +
+ +
+ ( + + {t("hosts.dpi", "DPI")} + + field.onChange(e.target.value ? parseInt(e.target.value) : undefined)} + /> + + + {t("hosts.dpiDesc", "Display resolution in DPI")} + + + )} + /> + + ( + + {t("hosts.colorDepth", "Color Depth")} + + + {t("hosts.colorDepthDesc", "Color depth for the remote display")} + + + )} + /> +
+ + {form.watch("connectionType") === "rdp" && ( + <> + ( + + {t("hosts.resizeMethod", "Resize Method")} + + + {t("hosts.resizeMethodDesc", "Method to use when resizing the display")} + + + )} + /> + + ( + +
+ {t("hosts.forceLossless", "Force Lossless")} + + {t("hosts.forceLosslessDesc", "Force lossless compression (higher bandwidth)")} + +
+ + + +
+ )} + /> + + )} + + {form.watch("connectionType") === "vnc" && ( + <> + ( + + {t("hosts.cursor", "Cursor Mode")} + + + {t("hosts.cursorDesc", "How to render the mouse cursor")} + + + )} + /> + + ( + +
+ {t("hosts.swapRedBlue", "Swap Red/Blue")} + + {t("hosts.swapRedBlueDesc", "Swap red and blue color components")} + +
+ + + +
+ )} + /> + + ( + +
+ {t("hosts.readOnly", "Read Only")} + + {t("hosts.readOnlyDesc", "View only mode - no input sent to server")} + +
+ + + +
+ )} + /> + + )} +
+ + {/* RDP/VNC Audio Tab */} + + + {t("hosts.audioSettings", "Audio Settings")} + + + ( + +
+ {t("hosts.disableAudio", "Disable Audio")} + + {t("hosts.disableAudioDesc", "Disable audio playback from the remote session")} + +
+ + + +
+ )} + /> + + {form.watch("connectionType") === "rdp" && ( + ( + +
+ {t("hosts.enableAudioInput", "Enable Audio Input")} + + {t("hosts.enableAudioInputDesc", "Enable microphone input to the remote session")} + +
+ + + +
+ )} + /> + )} +
+ + {/* RDP Performance Tab */} + + + {t("hosts.performanceSettings", "Performance Settings")} + + +
+ ( + +
+ {t("hosts.enableWallpaper", "Wallpaper")} + + {t("hosts.enableWallpaperDesc", "Show desktop wallpaper")} + +
+ + { + console.log("[HostManagerEditor] Wallpaper toggled to:", checked); + field.onChange(checked); + // Log the full guacamoleConfig after change + setTimeout(() => { + console.log("[HostManagerEditor] After toggle, guacamoleConfig:", form.getValues("guacamoleConfig")); + }, 0); + }} + /> + +
+ )} + /> + + ( + +
+ {t("hosts.enableTheming", "Theming")} + + {t("hosts.enableThemingDesc", "Enable window theming")} + +
+ + + +
+ )} + /> + + ( + +
+ {t("hosts.enableFontSmoothing", "Font Smoothing")} + + {t("hosts.enableFontSmoothingDesc", "Enable ClearType font smoothing")} + +
+ + + +
+ )} + /> + + ( + +
+ {t("hosts.enableFullWindowDrag", "Full Window Drag")} + + {t("hosts.enableFullWindowDragDesc", "Show window contents while dragging")} + +
+ + + +
+ )} + /> + + ( + +
+ {t("hosts.enableDesktopComposition", "Desktop Composition")} + + {t("hosts.enableDesktopCompositionDesc", "Enable Aero glass effects")} + +
+ + + +
+ )} + /> + + ( + +
+ {t("hosts.enableMenuAnimations", "Menu Animations")} + + {t("hosts.enableMenuAnimationsDesc", "Enable menu animations")} + +
+ + + +
+ )} + /> +
+ + + {t("hosts.cachingSettings", "Caching Settings")} + + +
+ ( + +
+ {t("hosts.disableBitmapCaching", "Disable Bitmap Caching")} + + {t("hosts.disableBitmapCachingDesc", "Disable bitmap caching")} + +
+ + + +
+ )} + /> + + ( + +
+ {t("hosts.disableOffscreenCaching", "Disable Offscreen Caching")} + + {t("hosts.disableOffscreenCachingDesc", "Disable offscreen caching")} + +
+ + + +
+ )} + /> + + ( + +
+ {t("hosts.disableGlyphCaching", "Disable Glyph Caching")} + + {t("hosts.disableGlyphCachingDesc", "Disable glyph caching")} + +
+ + + +
+ )} + /> + + ( + +
+ {t("hosts.disableGfx", "Disable GFX")} + + {t("hosts.disableGfxDesc", "Disable graphics pipeline extension")} + +
+ + + +
+ )} + /> +
+
diff --git a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx index 20907c3c..47f47266 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx @@ -1496,6 +1496,11 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { }); } else if (connectionType === "rdp" || connectionType === "vnc") { try { + // Parse guacamoleConfig if it's a string + const guacConfig = typeof host.guacamoleConfig === "string" + ? JSON.parse(host.guacamoleConfig) + : host.guacamoleConfig; + const tokenResponse = await getGuacamoleToken({ protocol: connectionType, hostname: host.ip, @@ -1505,6 +1510,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { domain: host.domain, security: host.security, ignoreCert: host.ignoreCert, + guacamoleConfig: guacConfig, }); addTab({ type: connectionType, diff --git a/src/ui/desktop/navigation/LeftSidebar.tsx b/src/ui/desktop/navigation/LeftSidebar.tsx index 90724a8c..164f00ac 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; diff --git a/src/ui/desktop/navigation/hosts/Host.tsx b/src/ui/desktop/navigation/hosts/Host.tsx index 05da70d6..e94fcddb 100644 --- a/src/ui/desktop/navigation/hosts/Host.tsx +++ b/src/ui/desktop/navigation/hosts/Host.tsx @@ -115,6 +115,16 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement { addTab({ type: "terminal", title, hostConfig: host }); } else if (connectionType === "rdp" || connectionType === "vnc") { try { + // Parse guacamoleConfig if it's a string + const guacConfig = typeof host.guacamoleConfig === "string" + ? JSON.parse(host.guacamoleConfig) + : host.guacamoleConfig; + + // Debug: log what guacamoleConfig we have + console.log("[Host.tsx] host.guacamoleConfig type:", typeof host.guacamoleConfig); + console.log("[Host.tsx] host.guacamoleConfig:", host.guacamoleConfig); + console.log("[Host.tsx] Parsed guacConfig:", guacConfig); + // Get guacamole token for RDP/VNC connection const tokenResponse = await getGuacamoleToken({ protocol: connectionType, @@ -125,6 +135,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement { domain: host.domain, security: host.security, ignoreCert: host.ignoreCert, + guacamoleConfig: guacConfig, }); addTab({ diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index d375e0c1..244e2b11 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -878,6 +878,8 @@ export async function createSSHHost(hostData: SSHHostData): Promise { domain: hostData.domain || null, security: hostData.security || null, ignoreCert: Boolean(hostData.ignoreCert), + // Guacamole configuration for RDP/VNC + guacamoleConfig: hostData.guacamoleConfig || null, }; if (!submitData.enableTunnel) { @@ -955,6 +957,8 @@ export async function updateSSHHost( domain: hostData.domain || null, security: hostData.security || null, ignoreCert: Boolean(hostData.ignoreCert), + // Guacamole configuration for RDP/VNC + guacamoleConfig: hostData.guacamoleConfig || null, }; if (!submitData.enableTunnel) { @@ -3142,16 +3146,171 @@ export interface GuacamoleTokenRequest { domain?: string; security?: string; ignoreCert?: boolean; + // Extended guacamole configuration + guacamoleConfig?: { + // Display settings + colorDepth?: number; + width?: number; + height?: number; + dpi?: number; + resizeMethod?: string; + forceLossless?: boolean; + // Audio settings + disableAudio?: boolean; + enableAudioInput?: boolean; + // RDP Performance settings + enableWallpaper?: boolean; + enableTheming?: boolean; + enableFontSmoothing?: boolean; + enableFullWindowDrag?: boolean; + enableDesktopComposition?: boolean; + enableMenuAnimations?: boolean; + disableBitmapCaching?: boolean; + disableOffscreenCaching?: boolean; + disableGlyphCaching?: boolean; + disableGfx?: boolean; + // RDP Device redirection + enablePrinting?: boolean; + printerName?: string; + enableDrive?: boolean; + driveName?: string; + drivePath?: string; + createDrivePath?: boolean; + disableDownload?: boolean; + disableUpload?: boolean; + enableTouch?: boolean; + // RDP Session settings + clientName?: string; + console?: boolean; + initialProgram?: string; + serverLayout?: string; + timezone?: string; + // RDP Gateway settings + gatewayHostname?: string; + gatewayPort?: number; + gatewayUsername?: string; + gatewayPassword?: string; + gatewayDomain?: string; + // RDP RemoteApp settings + remoteApp?: string; + remoteAppDir?: string; + remoteAppArgs?: string; + // Clipboard settings + normalizeClipboard?: string; + disableCopy?: boolean; + disablePaste?: boolean; + // VNC specific settings + cursor?: string; + swapRedBlue?: boolean; + readOnly?: boolean; + // Recording settings + recordingPath?: string; + recordingName?: string; + createRecordingPath?: boolean; + recordingExcludeOutput?: boolean; + recordingExcludeMouse?: boolean; + recordingIncludeKeys?: boolean; + // Wake-on-LAN settings + wolSendPacket?: boolean; + wolMacAddr?: string; + wolBroadcastAddr?: string; + wolUdpPort?: number; + wolWaitTime?: number; + }; } export interface GuacamoleTokenResponse { token: string; } +// Helper to convert camelCase to kebab-case for guacamole parameters +function toGuacamoleParams(config: GuacamoleTokenRequest["guacamoleConfig"]): Record { + if (!config) return {}; + + const params: Record = {}; + + // Map camelCase to guacamole's kebab-case parameter names + 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; + // Guacamole expects boolean values as strings "true" or "false" + if (typeof value === "boolean") { + params[paramName] = value ? "true" : "false"; + } else { + params[paramName] = value; + } + } + } + + return params; +} + export async function getGuacamoleToken( request: GuacamoleTokenRequest, ): Promise { try { + // Convert guacamoleConfig to guacamole parameter format + const guacParams = toGuacamoleParams(request.guacamoleConfig); + + // Debug: log guacamoleConfig and converted params + console.log("[Guacamole] Request guacamoleConfig:", request.guacamoleConfig); + console.log("[Guacamole] Converted params:", guacParams); + console.log("[Guacamole] Param count:", Object.keys(guacParams).length); + // Use authApi (port 30001 without /ssh prefix) since guacamole routes are at /guacamole const response = await authApi.post("/guacamole/token", { type: request.protocol, @@ -3162,6 +3321,7 @@ export async function getGuacamoleToken( domain: request.domain, security: request.security, "ignore-cert": request.ignoreCert, + ...guacParams, }); return response.data; } catch (error) {