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/database/db/index.ts b/src/backend/database/db/index.ts index ee65b059..b68c69e5 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -495,6 +495,13 @@ 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_data", "guacamole_config", "TEXT"); + 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..e57477be 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,12 @@ 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), + // RDP/VNC extended configuration (stored as JSON) + guacamoleConfig: text("guacamole_config"), 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..d1e9c0a1 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,11 @@ router.post( dockerConfig, terminalConfig, forceKeyboardInteractive, + // RDP/VNC specific fields + domain, + security, + ignoreCert, + guacamoleConfig, } = hostData; if ( !isNonEmptyString(userId) || @@ -261,8 +267,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 +296,11 @@ 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, + guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null, }; if (effectiveAuthType === "password") { @@ -352,6 +365,9 @@ router.post( dockerConfig: createdHost.dockerConfig ? JSON.parse(createdHost.dockerConfig as string) : undefined, + guacamoleConfig: createdHost.guacamoleConfig + ? JSON.parse(createdHost.guacamoleConfig as string) + : undefined, }; const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; @@ -448,6 +464,7 @@ router.put( } const { + connectionType, name, folder, tags, @@ -474,6 +491,11 @@ router.put( dockerConfig, terminalConfig, forceKeyboardInteractive, + // RDP/VNC specific fields + domain, + security, + ignoreCert, + guacamoleConfig, } = hostData; if ( !isNonEmptyString(userId) || @@ -494,6 +516,7 @@ router.put( const effectiveAuthType = authType || authMethod; const sshDataObj: Record = { + connectionType: connectionType || "ssh", name, folder, tags: Array.isArray(tags) ? tags.join(",") : tags || "", @@ -519,6 +542,11 @@ 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, + guacamoleConfig: guacamoleConfig ? JSON.stringify(guacamoleConfig) : null, }; if (effectiveAuthType === "password") { @@ -601,6 +629,9 @@ router.put( dockerConfig: updatedHost.dockerConfig ? JSON.parse(updatedHost.dockerConfig as string) : undefined, + guacamoleConfig: updatedHost.guacamoleConfig + ? JSON.parse(updatedHost.guacamoleConfig as string) + : undefined, }; const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; @@ -709,6 +740,9 @@ router.get( terminalConfig: row.terminalConfig ? JSON.parse(row.terminalConfig as string) : undefined, + guacamoleConfig: row.guacamoleConfig + ? JSON.parse(row.guacamoleConfig as string) + : undefined, forceKeyboardInteractive: row.forceKeyboardInteractive === "true", }; @@ -784,6 +818,9 @@ router.get( terminalConfig: host.terminalConfig ? JSON.parse(host.terminalConfig) : undefined, + guacamoleConfig: host.guacamoleConfig + ? JSON.parse(host.guacamoleConfig) + : undefined, forceKeyboardInteractive: host.forceKeyboardInteractive === "true", }; diff --git a/src/backend/guacamole/guacamole-server.ts b/src/backend/guacamole/guacamole-server.ts new file mode 100644 index 00000000..92100b1d --- /dev/null +++ b/src/backend/guacamole/guacamole-server.ts @@ -0,0 +1,108 @@ +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" }); + }, + }, + // 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", + "ignore-cert": true, + "enable-wallpaper": false, + "enable-font-smoothing": true, + "enable-desktop-composition": false, + "disable-audio": false, + "enable-drive": false, + "resize-method": "display-update", + width: 1280, + height: 720, + dpi: 96, + }, + vnc: { + "swap-red-blue": false, + "cursor": "remote", + width: 1280, + height: 720, + }, + telnet: { + "terminal-type": "xterm-256color", + }, + }, +}; + +// 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..dd0a955e --- /dev/null +++ b/src/backend/guacamole/routes.ts @@ -0,0 +1,159 @@ +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" }); + } + + // Log received options for debugging + guacLogger.info("Guacamole token request received", { + operation: "guac_token_request", + type, + hostname, + port, + optionKeys: Object.keys(options), + optionsCount: Object.keys(options).length, + }); + + // Log specific option values for debugging + if (Object.keys(options).length > 0) { + guacLogger.info("Guacamole options received", { + operation: "guac_token_options", + options: JSON.stringify(options), + }); + } + + 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/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts new file mode 100644 index 00000000..349d8a65 --- /dev/null +++ b/src/types/guacamole-common-js.d.ts @@ -0,0 +1,109 @@ +declare module "guacamole-common-js" { + namespace Guacamole { + class Client { + constructor(tunnel: Tunnel); + connect(data?: string): void; + disconnect(): void; + getDisplay(): Display; + sendKeyEvent(pressed: number, keysym: number): void; + sendMouseState(state: Mouse.State): void; + setClipboard(stream: OutputStream, mimetype: string): void; + createClipboardStream(mimetype: string): OutputStream; + onstatechange: ((state: number) => void) | null; + onerror: ((error: Status) => void) | null; + onclipboard: ((stream: InputStream, mimetype: string) => void) | null; + } + + class Display { + getElement(): HTMLElement; + getWidth(): number; + getHeight(): number; + scale(scale: number): void; + onresize: (() => void) | null; + } + + class Tunnel { + onerror: ((status: Status) => void) | null; + onstatechange: ((state: number) => void) | null; + } + + class WebSocketTunnel extends Tunnel { + constructor(url: string); + } + + class Mouse { + constructor(element: HTMLElement); + onmousedown: ((state: Mouse.State) => void) | null; + onmouseup: ((state: Mouse.State) => void) | null; + onmousemove: ((state: Mouse.State) => void) | null; + onmouseout: ((state: Mouse.State) => void) | null; + } + + namespace Mouse { + class State { + constructor( + x: number, + y: number, + left?: boolean, + middle?: boolean, + right?: boolean, + up?: boolean, + down?: boolean + ); + constructor(state: { + x: number; + y: number; + left?: boolean; + middle?: boolean; + right?: boolean; + up?: boolean; + down?: boolean; + }); + x: number; + y: number; + left: boolean; + middle: boolean; + right: boolean; + up: boolean; + down: boolean; + } + } + + class Keyboard { + constructor(element: Document | HTMLElement); + onkeydown: ((keysym: number) => void) | null; + onkeyup: ((keysym: number) => void) | null; + } + + class Status { + code: number; + message: string; + isError(): boolean; + } + + class InputStream { + onblob: ((data: string) => void) | null; + onend: (() => void) | null; + } + + class OutputStream { + sendBlob(data: string): void; + sendEnd(): void; + } + + class StringReader { + constructor(stream: InputStream); + ontext: ((text: string) => void) | null; + onend: (() => void) | null; + } + + class StringWriter { + constructor(stream: OutputStream); + sendText(text: string): void; + sendEnd(): void; + } + } + + export default Guacamole; +} + diff --git a/src/types/index.ts b/src/types/index.ts index 293d2f96..389610df 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -25,8 +25,102 @@ export interface DockerConfig { tlsKey?: string; } +export type HostConnectionType = "ssh" | "rdp" | "vnc" | "telnet"; + +// Guacamole configuration for RDP/VNC/Telnet connections +export interface 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; + // RDP Preconnection settings (Hyper-V) + preconnectionId?: number; + preconnectionBlob?: string; + // RDP Load balancing + loadBalanceInfo?: string; + // Clipboard settings + normalizeClipboard?: string; + disableCopy?: boolean; + disablePaste?: boolean; + // VNC specific settings + cursor?: string; + swapRedBlue?: boolean; + readOnly?: boolean; + // VNC Repeater settings + destHost?: string; + destPort?: number; + // VNC Reverse connection + reverseConnect?: boolean; + listenTimeout?: number; + // Common SFTP settings (for RDP/VNC file transfer) + enableSftp?: boolean; + sftpHostname?: string; + sftpPort?: number; + sftpUsername?: string; + sftpPassword?: string; + sftpPrivateKey?: string; + sftpDirectory?: string; + // 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 SSHHost { id: number; + connectionType: HostConnectionType; name: string; ip: string; port: number; @@ -59,6 +153,12 @@ export interface SSHHost { statsConfig?: string; dockerConfig?: string; terminalConfig?: TerminalConfig; + // RDP/VNC specific fields (basic) + domain?: string; + security?: string; + ignoreCert?: boolean; + // RDP/VNC extended configuration (stored as JSON) + guacamoleConfig?: GuacamoleConfig | string; createdAt: string; updatedAt: string; } @@ -73,6 +173,7 @@ export interface QuickActionData { } export interface SSHHostData { + connectionType?: HostConnectionType; name?: string; ip: string; port: number; @@ -99,6 +200,12 @@ export interface SSHHostData { statsConfig?: string | Record; dockerConfig?: DockerConfig | string; terminalConfig?: TerminalConfig; + // RDP/VNC specific fields (basic) + domain?: string; + security?: string; + ignoreCert?: boolean; + // RDP/VNC extended configuration + guacamoleConfig?: GuacamoleConfig; } export interface SSHFolder { @@ -361,11 +468,29 @@ export interface TabContextTab { | "admin" | "file_manager" | "user_profile" + | "rdp" + | "vnc" + | "tunnel" | "docker"; title: string; hostConfig?: SSHHost; terminalRef?: any; initialTab?: string; + connectionConfig?: { + token: string; + protocol: "rdp" | "vnc" | "telnet"; + 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 99cfa6d9..d5c0ee0c 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -156,6 +156,8 @@ function AppContent() { currentTabData?.type === "terminal" || currentTabData?.type === "server" || currentTabData?.type === "file_manager" || + currentTabData?.type === "rdp" || + currentTabData?.type === "vnc" || currentTabData?.type === "tunnel" || currentTabData?.type === "docker"; const showHome = currentTabData?.type === "home"; diff --git a/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx new file mode 100644 index 00000000..0780bcc2 --- /dev/null +++ b/src/ui/desktop/apps/guacamole/GuacamoleDisplay.tsx @@ -0,0 +1,386 @@ +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 { + // 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; + 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 containerRef = useRef(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); + + useImperativeHandle(ref, () => ({ + disconnect: () => { + if (clientRef.current) { + clientRef.current.disconnect(); + } + }, + sendKey: (keysym: number, pressed: boolean) => { + if (clientRef.current) { + clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym); + } + }, + sendMouse: (x: number, y: number, buttonMask: number) => { + if (clientRef.current) { + clientRef.current.sendMouseState( + new Guacamole.Mouse.State({ x, y, left: !!(buttonMask & 1), middle: !!(buttonMask & 2), right: !!(buttonMask & 4) }) + ); + } + }, + setClipboard: (data: string) => { + if (clientRef.current) { + const stream = clientRef.current.createClipboardStream("text/plain"); + const writer = new Guacamole.StringWriter(stream); + writer.sendText(data); + writer.sendEnd(); + } + }, + })); + + const getWebSocketUrl = useCallback(async (containerWidth: number, containerHeight: number): Promise => { + try { + let token: string; + + // If 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 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 + // Use actual container dimensions, fall back to 720p + const width = connectionConfig.width || containerWidth || 1280; + const height = connectionConfig.height || containerHeight || 720; + const dpi = connectionConfig.dpi || 96; + + 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)}&width=${width}&height=${height}&dpi=${dpi}`; + } 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); + + // Get container dimensions for the WebSocket URL + // Use the outer container ref which has h-full w-full + let containerWidth = containerRef.current?.clientWidth || 0; + let containerHeight = containerRef.current?.clientHeight || 0; + + console.log(`[Guacamole] Container size: ${containerWidth}x${containerHeight}`); + + // If container size is too small or unavailable, use 720p default + if (containerWidth < 100 || containerHeight < 100) { + console.log(`[Guacamole] Container too small, using 720p default`); + containerWidth = 1280; + containerHeight = 720; + } + + const wsUrl = await getWebSocketUrl(containerWidth, containerHeight); + 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(); + const displayElement = display.getElement(); + + if (displayRef.current) { + displayRef.current.innerHTML = ""; + displayRef.current.appendChild(displayElement); + } + + // 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); + scaleRef.current = scale; + display.scale(scale); + } + }; + + // Handle display sync (when frames arrive) + display.onresize = () => { + rescaleDisplay(); + }; + + // Set up mouse input on the display element (not the container) + // We need to adjust mouse coordinates based on the current scale factor + const mouse = new Guacamole.Mouse(displayElement); + 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); + 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 - the width/height/dpi are already in the WebSocket URL + client.connect(); + }, [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 - rescale display to fit container + useEffect(() => { + const handleResize = () => { + if (clientRef.current && containerRef.current) { + const display = clientRef.current.getDisplay(); + const cWidth = containerRef.current.clientWidth; + const cHeight = containerRef.current.clientHeight; + const displayWidth = display.getWidth(); + const displayHeight = display.getHeight(); + + if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) { + const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight); + scaleRef.current = scale; + display.scale(scale); + } + } + }; + + window.addEventListener("resize", handleResize); + // Also trigger on initial render after a short delay + const initialTimeout = setTimeout(handleResize, 100); + return () => { + window.removeEventListener("resize", handleResize); + clearTimeout(initialTimeout); + }; + }, []); + + return ( +
+
+ + {isConnecting && ( +
+
+ + + {t("guacamole.connecting", { type: (connectionConfig.protocol || connectionConfig.type || "remote").toUpperCase() })} + +
+
+ )} + + {connectionError && !isConnecting && ( +
+
+ {t("guacamole.connectionFailed")} + {connectionError} +
+
+ )} +
+ ); + } +); + diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index e0041c1a..6fbd41b7 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -78,7 +78,7 @@ import { DEFAULT_TERMINAL_CONFIG, } from "@/constants/terminal-themes"; import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx"; -import type { TerminalConfig } from "@/types"; +import type { TerminalConfig, SSHHost } from "@/types"; import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; interface JumpHostItemProps { @@ -277,46 +277,6 @@ function QuickActionItem({ ); } -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: Array<{ - sourcePort: number; - endpointPort: number; - endpointHost: string; - maxRetries: number; - retryInterval: number; - autoStart: boolean; - }>; - jumpHosts?: Array<{ - hostId: number; - }>; - quickActions?: Array<{ - name: string; - snippetId: number; - }>; - statsConfig?: StatsConfig; - terminalConfig?: TerminalConfig; - createdAt: string; - updatedAt: string; - credentialId?: number; -} - interface SSHManagerHostEditorProps { editingHost?: SSHHost | null; onFormSubmit?: (updatedHost?: SSHHost) => void; @@ -435,6 +395,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 +404,81 @@ 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), + // RDP/VNC extended configuration + guacamoleConfig: z.object({ + // Display settings + colorDepth: z.coerce.number().optional(), + width: z.coerce.number().optional(), + height: z.coerce.number().optional(), + dpi: z.coerce.number().optional(), + resizeMethod: z.string().optional(), + forceLossless: z.boolean().optional(), + // Audio settings + disableAudio: z.boolean().optional(), + enableAudioInput: z.boolean().optional(), + // RDP Performance settings + enableWallpaper: z.boolean().optional(), + enableTheming: z.boolean().optional(), + enableFontSmoothing: z.boolean().optional(), + enableFullWindowDrag: z.boolean().optional(), + enableDesktopComposition: z.boolean().optional(), + enableMenuAnimations: z.boolean().optional(), + disableBitmapCaching: z.boolean().optional(), + disableOffscreenCaching: z.boolean().optional(), + disableGlyphCaching: z.boolean().optional(), + disableGfx: z.boolean().optional(), + // RDP Device redirection + enablePrinting: z.boolean().optional(), + printerName: z.string().optional(), + enableDrive: z.boolean().optional(), + driveName: z.string().optional(), + drivePath: z.string().optional(), + createDrivePath: z.boolean().optional(), + disableDownload: z.boolean().optional(), + disableUpload: z.boolean().optional(), + enableTouch: z.boolean().optional(), + // RDP Session settings + clientName: z.string().optional(), + console: z.boolean().optional(), + initialProgram: z.string().optional(), + serverLayout: z.string().optional(), + timezone: z.string().optional(), + // RDP Gateway settings + gatewayHostname: z.string().optional(), + gatewayPort: z.coerce.number().optional(), + gatewayUsername: z.string().optional(), + gatewayPassword: z.string().optional(), + gatewayDomain: z.string().optional(), + // RDP RemoteApp settings + remoteApp: z.string().optional(), + remoteAppDir: z.string().optional(), + remoteAppArgs: z.string().optional(), + // Clipboard settings + normalizeClipboard: z.string().optional(), + disableCopy: z.boolean().optional(), + disablePaste: z.boolean().optional(), + // VNC specific settings + cursor: z.string().optional(), + swapRedBlue: z.boolean().optional(), + readOnly: z.boolean().optional(), + // Recording settings + recordingPath: z.string().optional(), + recordingName: z.string().optional(), + createRecordingPath: z.boolean().optional(), + recordingExcludeOutput: z.boolean().optional(), + recordingExcludeMouse: z.boolean().optional(), + recordingIncludeKeys: z.boolean().optional(), + // Wake-on-LAN settings + wolSendPacket: z.boolean().optional(), + wolMacAddr: z.string().optional(), + wolBroadcastAddr: z.string().optional(), + wolUdpPort: z.coerce.number().optional(), + wolWaitTime: z.coerce.number().optional(), + }).optional(), credentialId: z.number().optional().nullable(), overrideCredentialUsername: z.boolean().optional(), password: z.string().optional(), @@ -648,6 +684,7 @@ export function HostManagerEditor({ resolver: zodResolver(formSchema) as any, defaultValues: { name: "", + connectionType: "ssh" as const, ip: "", port: 22, username: "", @@ -682,6 +719,80 @@ export function HostManagerEditor({ tlsCert: "", tlsKey: "", }, + // RDP/VNC specific defaults + domain: "", + security: "", + ignoreCert: false, + guacamoleConfig: { + // Display settings + colorDepth: undefined, + width: undefined, + height: undefined, + dpi: 96, + resizeMethod: "display-update", + forceLossless: false, + // Audio settings + disableAudio: false, + enableAudioInput: false, + // RDP Performance settings + enableWallpaper: false, + enableTheming: false, + enableFontSmoothing: true, + enableFullWindowDrag: false, + enableDesktopComposition: false, + enableMenuAnimations: false, + disableBitmapCaching: false, + disableOffscreenCaching: false, + disableGlyphCaching: false, + disableGfx: false, + // RDP Device redirection + enablePrinting: false, + printerName: "", + enableDrive: false, + driveName: "", + drivePath: "", + createDrivePath: false, + disableDownload: false, + disableUpload: false, + enableTouch: false, + // RDP Session settings + clientName: "", + console: false, + initialProgram: "", + serverLayout: "en-us-qwerty", + timezone: "", + // RDP Gateway settings + gatewayHostname: "", + gatewayPort: 443, + gatewayUsername: "", + gatewayPassword: "", + gatewayDomain: "", + // RDP RemoteApp settings + remoteApp: "", + remoteAppDir: "", + remoteAppArgs: "", + // Clipboard settings + normalizeClipboard: "preserve", + disableCopy: false, + disablePaste: false, + // VNC specific settings + cursor: "remote", + swapRedBlue: false, + readOnly: false, + // Recording settings + recordingPath: "", + recordingName: "", + createRecordingPath: false, + recordingExcludeOutput: false, + recordingExcludeMouse: false, + recordingIncludeKeys: false, + // Wake-on-LAN settings + wolSendPacket: false, + wolMacAddr: "", + wolBroadcastAddr: "", + wolUdpPort: 9, + wolWaitTime: 0, + }, }, }); @@ -758,7 +869,83 @@ export function HostManagerEditor({ console.error("Failed to parse dockerConfig:", error); } + // Parse guacamoleConfig if it exists - merge with defaults + const defaultGuacamoleConfig = { + colorDepth: undefined, + width: undefined, + height: undefined, + dpi: 96, + resizeMethod: "display-update", + forceLossless: false, + disableAudio: false, + enableAudioInput: false, + enableWallpaper: false, + enableTheming: false, + enableFontSmoothing: true, + enableFullWindowDrag: false, + enableDesktopComposition: false, + enableMenuAnimations: false, + disableBitmapCaching: false, + disableOffscreenCaching: false, + disableGlyphCaching: false, + disableGfx: false, + enablePrinting: false, + printerName: "", + enableDrive: false, + driveName: "", + drivePath: "", + createDrivePath: false, + disableDownload: false, + disableUpload: false, + enableTouch: false, + clientName: "", + console: false, + initialProgram: "", + serverLayout: "en-us-qwerty", + timezone: "", + gatewayHostname: "", + gatewayPort: 443, + gatewayUsername: "", + gatewayPassword: "", + gatewayDomain: "", + remoteApp: "", + remoteAppDir: "", + remoteAppArgs: "", + normalizeClipboard: "preserve", + disableCopy: false, + disablePaste: false, + cursor: "remote", + swapRedBlue: false, + readOnly: false, + recordingPath: "", + recordingName: "", + createRecordingPath: false, + recordingExcludeOutput: false, + recordingExcludeMouse: false, + recordingIncludeKeys: false, + wolSendPacket: false, + wolMacAddr: "", + wolBroadcastAddr: "", + wolUdpPort: 9, + wolWaitTime: 0, + }; + let parsedGuacamoleConfig = { ...defaultGuacamoleConfig }; + try { + if (cleanedHost.guacamoleConfig) { + console.log("[HostManagerEditor] Loading host guacamoleConfig:", cleanedHost.guacamoleConfig); + const parsed = + typeof cleanedHost.guacamoleConfig === "string" + ? JSON.parse(cleanedHost.guacamoleConfig) + : cleanedHost.guacamoleConfig; + parsedGuacamoleConfig = { ...defaultGuacamoleConfig, ...parsed }; + console.log("[HostManagerEditor] Merged guacamoleConfig:", parsedGuacamoleConfig); + } + } catch (error) { + console.error("Failed to parse guacamoleConfig:", error); + } + const formData = { + connectionType: (cleanedHost.connectionType || "ssh") as "ssh" | "rdp" | "vnc" | "telnet", name: cleanedHost.name || "", ip: cleanedHost.ip || "", port: cleanedHost.port || 22, @@ -801,6 +988,12 @@ 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), + // Guacamole config for RDP/VNC + guacamoleConfig: parsedGuacamoleConfig, }; if (defaultAuthType === "password") { @@ -828,6 +1021,7 @@ export function HostManagerEditor({ } else { setAuthTab("password"); const defaultFormData = { + connectionType: "ssh" as const, name: "", ip: "", port: 22, @@ -863,6 +1057,10 @@ export function HostManagerEditor({ tlsCert: "", tlsKey: "", }, + // RDP/VNC specific defaults + domain: "", + security: "", + ignoreCert: false, }; form.reset(defaultFormData); @@ -884,6 +1082,12 @@ export function HostManagerEditor({ isSubmittingRef.current = true; setFormError(null); + // Debug: log form data being submitted + console.log("[HostManagerEditor] Form data on submit:", data); + console.log("[HostManagerEditor] data.guacamoleConfig:", data.guacamoleConfig); + console.log("[HostManagerEditor] data.guacamoleConfig.enableWallpaper:", data.guacamoleConfig?.enableWallpaper); + console.log("[HostManagerEditor] form.getValues('guacamoleConfig'):", form.getValues("guacamoleConfig")); + if (!data.name || data.name.trim() === "") { data.name = `${data.username}@${data.ip}`; } @@ -910,6 +1114,7 @@ export function HostManagerEditor({ } const submitData: Record = { + connectionType: data.connectionType || "ssh", name: data.name, ip: data.ip, port: data.port, @@ -931,8 +1136,17 @@ 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), + // Guacamole configuration for RDP/VNC + guacamoleConfig: data.guacamoleConfig || null, }; + // Debug: log what we're submitting + console.log("[HostManagerEditor] submitData.guacamoleConfig:", submitData.guacamoleConfig); + submitData.credentialId = null; submitData.password = null; submitData.key = null; @@ -1230,23 +1444,101 @@ 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")} + + + )} + {/* RDP tabs */} + {form.watch("connectionType") === "rdp" && ( + + + {t("hosts.general")} + + + {t("hosts.display", "Display")} + + + {t("hosts.audio", "Audio")} + + + {t("hosts.performance", "Performance")} + + + + )} + {/* VNC tabs */} + {form.watch("connectionType") === "vnc" && ( + + + {t("hosts.general")} + + + {t("hosts.display", "Display")} + + + {t("hosts.audio", "Audio")} + + + )} + + {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 +1606,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 +1817,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")} + + + + + )} + /> +
+ + )}
+ + {/* 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 4c8cf5fb..47f47266 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx @@ -61,7 +61,10 @@ import { HardDrive, Globe, FolderOpen, + Monitor, + ScreenShare, } from "lucide-react"; +import { getGuacamoleToken } from "@/ui/main-axios.ts"; import type { SSHHost, SSHFolder, @@ -1371,7 +1374,28 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { )}
- {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/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx index 3d2f845c..d6c65895 100644 --- a/src/ui/desktop/navigation/AppView.tsx +++ b/src/ui/desktop/navigation/AppView.tsx @@ -2,6 +2,10 @@ import React, { useEffect, useRef, useState, useMemo } from "react"; import { Terminal } from "@/ui/desktop/apps/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 { 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"; @@ -18,7 +22,6 @@ import { TERMINAL_THEMES, DEFAULT_TERMINAL_CONFIG, } from "@/constants/terminal-themes"; -import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; interface TabData { id: number; @@ -32,6 +35,7 @@ interface TabData { }; }; hostConfig?: any; + connectionConfig?: GuacamoleConnectionConfig; [key: string]: unknown; } @@ -61,6 +65,8 @@ export function AppView({ tab.type === "terminal" || tab.type === "server" || tab.type === "file_manager" || + tab.type === "rdp" || + tab.type === "vnc" || tab.type === "tunnel" || tab.type === "docker", ), @@ -337,6 +343,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 +
+ ) ) : t.type === "tunnel" ? ( 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..e94fcddb 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,49 @@ 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 { + // 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, + hostname: host.ip, + port: host.port, + username: host.username, + password: host.password || "", + domain: host.domain, + security: host.security, + ignoreCert: host.ignoreCert, + guacamoleConfig: guacConfig, + }); + + 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 +170,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 +192,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {