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)} + /> +
+ )} +
+
+ ); +} +