WIP: Guacd, RDP, Docker-Compose #451

Closed
starhound wants to merge 9 commits from starhound/guacd-docker-compose into dev-1.10.0
26 changed files with 2739 additions and 191 deletions

49
docker/docker-compose.yml Normal file
View File

@@ -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

View File

@@ -203,6 +203,41 @@ http {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; 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/ { location /ssh/tunnel/ {
proxy_pass http://127.0.0.1:30003; proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@@ -200,6 +200,41 @@ http {
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; 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/ { location /ssh/tunnel/ {
proxy_pass http://127.0.0.1:30003; proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1; proxy_http_version 1.1;

29
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "1.8.1", "version": "1.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "1.8.1", "version": "1.9.0",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.7", "@codemirror/autocomplete": "^6.18.7",
"@codemirror/commands": "^6.3.3", "@codemirror/commands": "^6.3.3",
@@ -33,6 +33,7 @@
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.9",
"@types/guacamole-common-js": "^1.5.5",
"@types/jszip": "^3.4.0", "@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
@@ -57,6 +58,8 @@
"dotenv": "^17.2.0", "dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3", "drizzle-orm": "^0.44.3",
"express": "^5.1.0", "express": "^5.1.0",
"guacamole-common-js": "^1.5.0",
"guacamole-lite": "^1.2.0",
"i18next": "^25.4.2", "i18next": "^25.4.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"jose": "^5.2.3", "jose": "^5.2.3",
@@ -5030,6 +5033,11 @@
"@types/node": "*" "@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": { "node_modules/@types/hast": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -9812,6 +9820,23 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",

View File

@@ -52,6 +52,7 @@
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.9",
"@types/guacamole-common-js": "^1.5.5",
"@types/jszip": "^3.4.0", "@types/jszip": "^3.4.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
@@ -76,6 +77,8 @@
"dotenv": "^17.2.0", "dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3", "drizzle-orm": "^0.44.3",
"express": "^5.1.0", "express": "^5.1.0",
"guacamole-common-js": "^1.5.0",
"guacamole-lite": "^1.2.0",
"i18next": "^25.4.2", "i18next": "^25.4.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"jose": "^5.2.3", "jose": "^5.2.3",

View File

@@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js";
import credentialsRoutes from "./routes/credentials.js"; import credentialsRoutes from "./routes/credentials.js";
import snippetsRoutes from "./routes/snippets.js"; import snippetsRoutes from "./routes/snippets.js";
import terminalRoutes from "./routes/terminal.js"; import terminalRoutes from "./routes/terminal.js";
import guacamoleRoutes from "../guacamole/routes.js";
import cors from "cors"; import cors from "cors";
import fetch from "node-fetch"; import fetch from "node-fetch";
import fs from "fs"; import fs from "fs";
@@ -1436,6 +1437,7 @@ app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes); app.use("/credentials", credentialsRoutes);
app.use("/snippets", snippetsRoutes); app.use("/snippets", snippetsRoutes);
app.use("/terminal", terminalRoutes); app.use("/terminal", terminalRoutes);
app.use("/guacamole", guacamoleRoutes);
app.use( app.use(
( (

View File

@@ -495,6 +495,13 @@ const migrateSchema = () => {
); );
addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); 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", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT"); addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");

View File

@@ -52,6 +52,8 @@ export const sshData = sqliteTable("ssh_data", {
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
// Connection type: ssh, rdp, vnc, telnet
connectionType: text("connection_type").notNull().default("ssh"),
name: text("name"), name: text("name"),
ip: text("ip").notNull(), ip: text("ip").notNull(),
port: integer("port").notNull(), port: integer("port").notNull(),
@@ -94,6 +96,12 @@ export const sshData = sqliteTable("ssh_data", {
dockerConfig: text("docker_config"), dockerConfig: text("docker_config"),
terminalConfig: text("terminal_config"), terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"), 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") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),

View File

@@ -218,6 +218,7 @@ router.post(
} }
const { const {
connectionType,
name, name,
folder, folder,
tags, tags,
@@ -244,6 +245,11 @@ router.post(
dockerConfig, dockerConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
// RDP/VNC specific fields
domain,
security,
ignoreCert,
guacamoleConfig,
} = hostData; } = hostData;
if ( if (
!isNonEmptyString(userId) || !isNonEmptyString(userId) ||
@@ -261,8 +267,10 @@ router.post(
} }
const effectiveAuthType = authType || authMethod; const effectiveAuthType = authType || authMethod;
const effectiveConnectionType = connectionType || "ssh";
const sshDataObj: Record<string, unknown> = { const sshDataObj: Record<string, unknown> = {
userId: userId, userId: userId,
connectionType: effectiveConnectionType,
name, name,
folder: folder || null, folder: folder || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "", tags: Array.isArray(tags) ? tags.join(",") : tags || "",
@@ -288,6 +296,11 @@ router.post(
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null, dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", 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") { if (effectiveAuthType === "password") {
@@ -352,6 +365,9 @@ router.post(
dockerConfig: createdHost.dockerConfig dockerConfig: createdHost.dockerConfig
? JSON.parse(createdHost.dockerConfig as string) ? JSON.parse(createdHost.dockerConfig as string)
: undefined, : undefined,
guacamoleConfig: createdHost.guacamoleConfig
? JSON.parse(createdHost.guacamoleConfig as string)
: undefined,
}; };
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -448,6 +464,7 @@ router.put(
} }
const { const {
connectionType,
name, name,
folder, folder,
tags, tags,
@@ -474,6 +491,11 @@ router.put(
dockerConfig, dockerConfig,
terminalConfig, terminalConfig,
forceKeyboardInteractive, forceKeyboardInteractive,
// RDP/VNC specific fields
domain,
security,
ignoreCert,
guacamoleConfig,
} = hostData; } = hostData;
if ( if (
!isNonEmptyString(userId) || !isNonEmptyString(userId) ||
@@ -494,6 +516,7 @@ router.put(
const effectiveAuthType = authType || authMethod; const effectiveAuthType = authType || authMethod;
const sshDataObj: Record<string, unknown> = { const sshDataObj: Record<string, unknown> = {
connectionType: connectionType || "ssh",
name, name,
folder, folder,
tags: Array.isArray(tags) ? tags.join(",") : tags || "", tags: Array.isArray(tags) ? tags.join(",") : tags || "",
@@ -519,6 +542,11 @@ router.put(
dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null, dockerConfig: dockerConfig ? JSON.stringify(dockerConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false", 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") { if (effectiveAuthType === "password") {
@@ -601,6 +629,9 @@ router.put(
dockerConfig: updatedHost.dockerConfig dockerConfig: updatedHost.dockerConfig
? JSON.parse(updatedHost.dockerConfig as string) ? JSON.parse(updatedHost.dockerConfig as string)
: undefined, : undefined,
guacamoleConfig: updatedHost.guacamoleConfig
? JSON.parse(updatedHost.guacamoleConfig as string)
: undefined,
}; };
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -709,6 +740,9 @@ router.get(
terminalConfig: row.terminalConfig terminalConfig: row.terminalConfig
? JSON.parse(row.terminalConfig as string) ? JSON.parse(row.terminalConfig as string)
: undefined, : undefined,
guacamoleConfig: row.guacamoleConfig
? JSON.parse(row.guacamoleConfig as string)
: undefined,
forceKeyboardInteractive: row.forceKeyboardInteractive === "true", forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
}; };
@@ -784,6 +818,9 @@ router.get(
terminalConfig: host.terminalConfig terminalConfig: host.terminalConfig
? JSON.parse(host.terminalConfig) ? JSON.parse(host.terminalConfig)
: undefined, : undefined,
guacamoleConfig: host.guacamoleConfig
? JSON.parse(host.guacamoleConfig)
: undefined,
forceKeyboardInteractive: host.forceKeyboardInteractive === "true", forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
}; };

View File

@@ -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<string, unknown> }) => {
guacLogger.info("Guacamole connection opened", {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.type,
});
});
guacServer.on("close", (clientConnection: { connectionSettings?: Record<string, unknown> }) => {
guacLogger.info("Guacamole connection closed", {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.type,
});
});
guacServer.on("error", (clientConnection: { connectionSettings?: Record<string, unknown> }, 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 };

View File

@@ -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<boolean> => {
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;

View File

@@ -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<GuacamoleConnectionSettings["settings"]> = {}
): 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<GuacamoleConnectionSettings["settings"]> = {}
): 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<GuacamoleConnectionSettings["settings"]> = {}
): string {
const token: GuacamoleToken = {
connection: {
type: "telnet",
settings: {
hostname,
username,
password,
port: 23,
...options,
},
},
};
return this.encryptToken(token);
}
}

View File

@@ -104,6 +104,19 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
await import("./ssh/server-stats.js"); await import("./ssh/server-stats.js");
await import("./dashboard.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", () => { process.on("SIGINT", () => {
systemLogger.info( systemLogger.info(
"Received SIGINT signal, initiating graceful shutdown...", "Received SIGINT signal, initiating graceful shutdown...",

View File

@@ -254,5 +254,6 @@ export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6"); export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6"); export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899"); export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b");
export const logger = systemLogger; export const logger = systemLogger;

109
src/types/guacamole-common-js.d.ts vendored Normal file
View File

@@ -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;
}

View File

@@ -25,8 +25,102 @@ export interface DockerConfig {
tlsKey?: string; 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 { export interface SSHHost {
id: number; id: number;
connectionType: HostConnectionType;
name: string; name: string;
ip: string; ip: string;
port: number; port: number;
@@ -59,6 +153,12 @@ export interface SSHHost {
statsConfig?: string; statsConfig?: string;
dockerConfig?: string; dockerConfig?: string;
terminalConfig?: TerminalConfig; 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; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -73,6 +173,7 @@ export interface QuickActionData {
} }
export interface SSHHostData { export interface SSHHostData {
connectionType?: HostConnectionType;
name?: string; name?: string;
ip: string; ip: string;
port: number; port: number;
@@ -99,6 +200,12 @@ export interface SSHHostData {
statsConfig?: string | Record<string, unknown>; statsConfig?: string | Record<string, unknown>;
dockerConfig?: DockerConfig | string; dockerConfig?: DockerConfig | string;
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
// RDP/VNC specific fields (basic)
domain?: string;
security?: string;
ignoreCert?: boolean;
// RDP/VNC extended configuration
guacamoleConfig?: GuacamoleConfig;
} }
export interface SSHFolder { export interface SSHFolder {
@@ -361,11 +468,29 @@ export interface TabContextTab {
| "admin" | "admin"
| "file_manager" | "file_manager"
| "user_profile" | "user_profile"
| "rdp"
| "vnc"
| "tunnel"
| "docker"; | "docker";
title: string; title: string;
hostConfig?: SSHHost; hostConfig?: SSHHost;
terminalRef?: any; terminalRef?: any;
initialTab?: string; 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"; export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid";

View File

@@ -156,6 +156,8 @@ function AppContent() {
currentTabData?.type === "terminal" || currentTabData?.type === "terminal" ||
currentTabData?.type === "server" || currentTabData?.type === "server" ||
currentTabData?.type === "file_manager" || currentTabData?.type === "file_manager" ||
currentTabData?.type === "rdp" ||
currentTabData?.type === "vnc" ||
currentTabData?.type === "tunnel" || currentTabData?.type === "tunnel" ||
currentTabData?.type === "docker"; currentTabData?.type === "docker";
const showHome = currentTabData?.type === "home"; const showHome = currentTabData?.type === "home";

View File

@@ -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<GuacamoleDisplayHandle, GuacamoleDisplayProps>(
function GuacamoleDisplay(
{ connectionConfig, isVisible, onConnect, onDisconnect, onError },
ref
) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null); // Outer container for measuring size
const displayRef = useRef<HTMLDivElement>(null); // Inner div for guacamole canvas
const clientRef = useRef<Guacamole.Client | null>(null);
const scaleRef = useRef<number>(1); // Track current scale factor for mouse
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(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<string | null> => {
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 (
<div
ref={containerRef}
className="h-full w-full relative bg-black flex items-center justify-center overflow-hidden"
>
<div
ref={displayRef}
className="relative"
style={{ cursor: isConnected ? "none" : "default" }}
/>
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span className="text-muted-foreground">
{t("guacamole.connecting", { type: (connectionConfig.protocol || connectionConfig.type || "remote").toUpperCase() })}
</span>
</div>
</div>
)}
{connectionError && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
<div className="flex flex-col items-center gap-4 text-center p-4">
<span className="text-destructive font-medium">{t("guacamole.connectionFailed")}</span>
<span className="text-muted-foreground text-sm">{connectionError}</span>
</div>
</div>
)}
</div>
);
}
);

File diff suppressed because it is too large Load Diff

View File

@@ -61,7 +61,10 @@ import {
HardDrive, HardDrive,
Globe, Globe,
FolderOpen, FolderOpen,
Monitor,
ScreenShare,
} from "lucide-react"; } from "lucide-react";
import { getGuacamoleToken } from "@/ui/main-axios.ts";
import type { import type {
SSHHost, SSHHost,
SSHFolder, SSHFolder,
@@ -1371,7 +1374,28 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
)} )}
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{host.enableTerminal && ( {/* Show connection type badge */}
{(host.connectionType === "rdp" || host.connectionType === "vnc") ? (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
{host.connectionType === "rdp" ? (
<Monitor className="h-2 w-2 mr-0.5" />
) : (
<ScreenShare className="h-2 w-2 mr-0.5" />
)}
{host.connectionType.toUpperCase()}
</Badge>
) : host.connectionType === "telnet" ? (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Terminal className="h-2 w-2 mr-0.5" />
Telnet
</Badge>
) : host.enableTerminal && (
<Badge <Badge
variant="outline" variant="outline"
className="text-xs px-1 py-0" className="text-xs px-1 py-0"
@@ -1450,30 +1474,72 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</div> </div>
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1"> <div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
{host.enableTerminal && ( {/* Show connect button for SSH/Telnet if enableTerminal, or always for RDP/VNC */}
{(host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={(e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
const title = host.name?.trim() const title = host.name?.trim()
? host.name ? host.name
: `${host.username}@${host.ip}:${host.port}`; : `${host.username}@${host.ip}:${host.port}`;
const connectionType = host.connectionType || "ssh";
if (connectionType === "ssh" || connectionType === "telnet") {
addTab({ addTab({
type: "terminal", type: "terminal",
title, title,
hostConfig: host, 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;
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);
toast.error(`Failed to connect to ${connectionType.toUpperCase()} host`);
}
}
}} }}
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1" className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
> >
{host.connectionType === "rdp" ? (
<Monitor className="h-3.5 w-3.5" />
) : host.connectionType === "vnc" ? (
<ScreenShare className="h-3.5 w-3.5" />
) : (
<Terminal className="h-3.5 w-3.5" /> <Terminal className="h-3.5 w-3.5" />
)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Open Terminal</p> <p>{host.connectionType === "rdp" ? "Open RDP" : host.connectionType === "vnc" ? "Open VNC" : "Open Terminal"}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}

View File

@@ -2,6 +2,10 @@ import React, { useEffect, useRef, useState, useMemo } from "react";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx"; import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx";
import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx"; import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx";
import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.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 { TunnelManager } from "@/ui/desktop/apps/tunnel/TunnelManager.tsx";
import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx"; import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
@@ -18,7 +22,6 @@ import {
TERMINAL_THEMES, TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG, DEFAULT_TERMINAL_CONFIG,
} from "@/constants/terminal-themes"; } from "@/constants/terminal-themes";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
interface TabData { interface TabData {
id: number; id: number;
@@ -32,6 +35,7 @@ interface TabData {
}; };
}; };
hostConfig?: any; hostConfig?: any;
connectionConfig?: GuacamoleConnectionConfig;
[key: string]: unknown; [key: string]: unknown;
} }
@@ -61,6 +65,8 @@ export function AppView({
tab.type === "terminal" || tab.type === "terminal" ||
tab.type === "server" || tab.type === "server" ||
tab.type === "file_manager" || tab.type === "file_manager" ||
tab.type === "rdp" ||
tab.type === "vnc" ||
tab.type === "tunnel" || tab.type === "tunnel" ||
tab.type === "docker", tab.type === "docker",
), ),
@@ -337,6 +343,19 @@ export function AppView({
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
embedded embedded
/> />
) : t.type === "rdp" || t.type === "vnc" ? (
t.connectionConfig ? (
<GuacamoleDisplay
connectionConfig={t.connectionConfig}
isVisible={effectiveVisible}
onDisconnect={() => removeTab(t.id)}
onError={(err) => console.error("Guacamole error:", err)}
/>
) : (
<div className="flex items-center justify-center h-full text-red-500">
Missing connection configuration
</div>
)
) : t.type === "tunnel" ? ( ) : t.type === "tunnel" ? (
<TunnelManager <TunnelManager
hostConfig={t.hostConfig} hostConfig={t.hostConfig}

View File

@@ -36,30 +36,7 @@ import { Button } from "@/components/ui/button.tsx";
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx"; import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts"; import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { SSHFolder } from "@/types/index.ts"; import type { SSHFolder, SSHHost } from "@/types/index.ts";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: unknown[];
createdAt: string;
updatedAt: string;
}
interface SidebarProps { interface SidebarProps {
disabled?: boolean; disabled?: boolean;

View File

@@ -374,6 +374,8 @@ export function TopNavbar({
const isSshManager = tab.type === "ssh_manager"; const isSshManager = tab.type === "ssh_manager";
const isAdmin = tab.type === "admin"; const isAdmin = tab.type === "admin";
const isUserProfile = tab.type === "user_profile"; const isUserProfile = tab.type === "user_profile";
const isRdp = tab.type === "rdp";
const isVnc = tab.type === "vnc";
const isSplittable = const isSplittable =
isTerminal || isServer || isFileManager || isTunnel || isDocker; isTerminal || isServer || isFileManager || isTunnel || isDocker;
const disableSplit = !isSplittable; const disableSplit = !isSplittable;
@@ -491,7 +493,9 @@ export function TopNavbar({
isDocker || isDocker ||
isSshManager || isSshManager ||
isAdmin || isAdmin ||
isUserProfile isUserProfile ||
isRdp ||
isVnc
? () => handleTabClose(tab.id) ? () => handleTabClose(tab.id)
: undefined : undefined
} }
@@ -507,7 +511,9 @@ export function TopNavbar({
isDocker || isDocker ||
isSshManager || isSshManager ||
isAdmin || isAdmin ||
isUserProfile isUserProfile ||
isRdp ||
isVnc
} }
disableActivate={disableActivate} disableActivate={disableActivate}
disableSplit={disableSplit} disableSplit={disableSplit}

View File

@@ -10,6 +10,8 @@ import {
Pencil, Pencil,
ArrowDownUp, ArrowDownUp,
Container, Container,
Monitor,
ScreenShare,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -18,7 +20,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; 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 type { HostProps } from "../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
@@ -106,8 +108,49 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}; };
}, [host.id, shouldShowStatus]); }, [host.id, shouldShowStatus]);
const handleTerminalClick = () => { const handleTerminalClick = async () => {
const connectionType = host.connectionType || "ssh";
if (connectionType === "ssh" || connectionType === "telnet") {
addTab({ type: "terminal", title, hostConfig: host }); 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 ( return (
@@ -127,13 +170,20 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
</p> </p>
<ButtonGroup className="flex-shrink-0"> <ButtonGroup className="flex-shrink-0">
{host.enableTerminal && ( {/* Show connect button for SSH/Telnet if enableTerminal, or always for RDP/VNC */}
{(host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") && (
<Button <Button
variant="outline" variant="outline"
className="!px-2 border-1 border-dark-border" className="!px-2 border-1 border-dark-border"
onClick={handleTerminalClick} onClick={handleTerminalClick}
> >
{host.connectionType === "rdp" ? (
<Monitor />
) : host.connectionType === "vnc" ? (
<ScreenShare />
) : (
<Terminal /> <Terminal />
)}
</Button> </Button>
)} )}
@@ -142,7 +192,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<Button <Button
variant="outline" variant="outline"
className={`!px-2 border-1 border-dark-border ${ className={`!px-2 border-1 border-dark-border ${
host.enableTerminal ? "rounded-tl-none rounded-bl-none" : "" (host.enableTerminal || host.connectionType === "rdp" || host.connectionType === "vnc") ? "rounded-tl-none rounded-bl-none" : ""
}`} }`}
> >
<EllipsisVertical /> <EllipsisVertical />
@@ -154,6 +204,9 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
side="right" side="right"
className="w-56 bg-dark-bg border-dark-border text-white" className="w-56 bg-dark-bg border-dark-border text-white"
> >
{/* SSH-specific menu items */}
{(!host.connectionType || host.connectionType === "ssh") && (
<>
{shouldShowMetrics && ( {shouldShowMetrics && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -198,6 +251,8 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<span className="flex-1">Open Docker</span> <span className="flex-1">Open Docker</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
addTab({ addTab({

View File

@@ -10,6 +10,7 @@ import {
Server as ServerIcon, Server as ServerIcon,
Folder as FolderIcon, Folder as FolderIcon,
User as UserIcon, User as UserIcon,
Monitor as MonitorIcon,
ArrowDownUp as TunnelIcon, ArrowDownUp as TunnelIcon,
Container as DockerIcon, Container as DockerIcon,
} from "lucide-react"; } from "lucide-react";
@@ -121,15 +122,18 @@ export function Tab({
tabType === "terminal" || tabType === "terminal" ||
tabType === "server" || tabType === "server" ||
tabType === "file_manager" || tabType === "file_manager" ||
tabType === "user_profile" ||
tabType === "rdp" ||
tabType === "vnc" ||
tabType === "tunnel" || tabType === "tunnel" ||
tabType === "docker" || tabType === "docker"
tabType === "user_profile"
) { ) {
const isServer = tabType === "server"; const isServer = tabType === "server";
const isFileManager = tabType === "file_manager"; const isFileManager = tabType === "file_manager";
const isTunnel = tabType === "tunnel"; const isTunnel = tabType === "tunnel";
const isDocker = tabType === "docker"; const isDocker = tabType === "docker";
const isUserProfile = tabType === "user_profile"; const isUserProfile = tabType === "user_profile";
const isRemoteDesktop = tabType === "rdp" || tabType === "vnc";
const displayTitle = const displayTitle =
title || title ||
@@ -143,6 +147,8 @@ export function Tab({
? t("nav.docker") ? t("nav.docker")
: isUserProfile : isUserProfile
? t("nav.userProfile") ? t("nav.userProfile")
: isRemoteDesktop
? tabType.toUpperCase()
: t("nav.terminal")); : t("nav.terminal"));
const { base, suffix } = splitTitle(displayTitle); const { base, suffix } = splitTitle(displayTitle);
@@ -167,6 +173,8 @@ export function Tab({
<DockerIcon className="h-4 w-4 flex-shrink-0" /> <DockerIcon className="h-4 w-4 flex-shrink-0" />
) : isUserProfile ? ( ) : isUserProfile ? (
<UserIcon className="h-4 w-4 flex-shrink-0" /> <UserIcon className="h-4 w-4 flex-shrink-0" />
) : isRemoteDesktop ? (
<MonitorIcon className="h-4 w-4 flex-shrink-0" />
) : ( ) : (
<TerminalIcon className="h-4 w-4 flex-shrink-0" /> <TerminalIcon className="h-4 w-4 flex-shrink-0" />
)} )}

View File

@@ -838,6 +838,7 @@ export async function getSSHHosts(): Promise<SSHHost[]> {
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> { export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try { try {
const submitData = { const submitData = {
connectionType: hostData.connectionType || "ssh",
name: hostData.name || "", name: hostData.name || "",
ip: hostData.ip, ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22, port: parseInt(hostData.port.toString()) || 22,
@@ -873,6 +874,12 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
: null, : null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
// RDP/VNC specific fields
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
// Guacamole configuration for RDP/VNC
guacamoleConfig: hostData.guacamoleConfig || null,
}; };
if (!submitData.enableTunnel) { if (!submitData.enableTunnel) {
@@ -910,6 +917,7 @@ export async function updateSSHHost(
): Promise<SSHHost> { ): Promise<SSHHost> {
try { try {
const submitData = { const submitData = {
connectionType: hostData.connectionType || "ssh",
name: hostData.name || "", name: hostData.name || "",
ip: hostData.ip, ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22, port: parseInt(hostData.port.toString()) || 22,
@@ -945,6 +953,12 @@ export async function updateSSHHost(
: null, : null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
// RDP/VNC specific fields
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
// Guacamole configuration for RDP/VNC
guacamoleConfig: hostData.guacamoleConfig || null,
}; };
if (!submitData.enableTunnel) { if (!submitData.enableTunnel) {
@@ -3121,3 +3135,196 @@ export async function unlinkOIDCFromPasswordAccount(
throw handleApiError(error, "unlink OIDC from password account"); throw handleApiError(error, "unlink OIDC from password account");
} }
} }
// Guacamole API functions
export interface GuacamoleTokenRequest {
protocol: "rdp" | "vnc" | "telnet";
hostname: string;
port?: number;
username?: string;
password?: string;
domain?: string;
security?: string;
ignoreCert?: boolean;
// Extended guacamole configuration
guacamoleConfig?: {
// Display settings
colorDepth?: number;
width?: number;
height?: number;
dpi?: number;
resizeMethod?: string;
forceLossless?: boolean;
// Audio settings
disableAudio?: boolean;
enableAudioInput?: boolean;
// RDP Performance settings
enableWallpaper?: boolean;
enableTheming?: boolean;
enableFontSmoothing?: boolean;
enableFullWindowDrag?: boolean;
enableDesktopComposition?: boolean;
enableMenuAnimations?: boolean;
disableBitmapCaching?: boolean;
disableOffscreenCaching?: boolean;
disableGlyphCaching?: boolean;
disableGfx?: boolean;
// RDP Device redirection
enablePrinting?: boolean;
printerName?: string;
enableDrive?: boolean;
driveName?: string;
drivePath?: string;
createDrivePath?: boolean;
disableDownload?: boolean;
disableUpload?: boolean;
enableTouch?: boolean;
// RDP Session settings
clientName?: string;
console?: boolean;
initialProgram?: string;
serverLayout?: string;
timezone?: string;
// RDP Gateway settings
gatewayHostname?: string;
gatewayPort?: number;
gatewayUsername?: string;
gatewayPassword?: string;
gatewayDomain?: string;
// RDP RemoteApp settings
remoteApp?: string;
remoteAppDir?: string;
remoteAppArgs?: string;
// Clipboard settings
normalizeClipboard?: string;
disableCopy?: boolean;
disablePaste?: boolean;
// VNC specific settings
cursor?: string;
swapRedBlue?: boolean;
readOnly?: boolean;
// Recording settings
recordingPath?: string;
recordingName?: string;
createRecordingPath?: boolean;
recordingExcludeOutput?: boolean;
recordingExcludeMouse?: boolean;
recordingIncludeKeys?: boolean;
// Wake-on-LAN settings
wolSendPacket?: boolean;
wolMacAddr?: string;
wolBroadcastAddr?: string;
wolUdpPort?: number;
wolWaitTime?: number;
};
}
export interface GuacamoleTokenResponse {
token: string;
}
// Helper to convert camelCase to kebab-case for guacamole parameters
function toGuacamoleParams(config: GuacamoleTokenRequest["guacamoleConfig"]): Record<string, unknown> {
if (!config) return {};
const params: Record<string, unknown> = {};
// Map camelCase to guacamole's kebab-case parameter names
const mappings: Record<string, string> = {
colorDepth: "color-depth",
resizeMethod: "resize-method",
forceLossless: "force-lossless",
disableAudio: "disable-audio",
enableAudioInput: "enable-audio-input",
enableWallpaper: "enable-wallpaper",
enableTheming: "enable-theming",
enableFontSmoothing: "enable-font-smoothing",
enableFullWindowDrag: "enable-full-window-drag",
enableDesktopComposition: "enable-desktop-composition",
enableMenuAnimations: "enable-menu-animations",
disableBitmapCaching: "disable-bitmap-caching",
disableOffscreenCaching: "disable-offscreen-caching",
disableGlyphCaching: "disable-glyph-caching",
disableGfx: "disable-gfx",
enablePrinting: "enable-printing",
printerName: "printer-name",
enableDrive: "enable-drive",
driveName: "drive-name",
drivePath: "drive-path",
createDrivePath: "create-drive-path",
disableDownload: "disable-download",
disableUpload: "disable-upload",
enableTouch: "enable-touch",
clientName: "client-name",
initialProgram: "initial-program",
serverLayout: "server-layout",
gatewayHostname: "gateway-hostname",
gatewayPort: "gateway-port",
gatewayUsername: "gateway-username",
gatewayPassword: "gateway-password",
gatewayDomain: "gateway-domain",
remoteApp: "remote-app",
remoteAppDir: "remote-app-dir",
remoteAppArgs: "remote-app-args",
normalizeClipboard: "normalize-clipboard",
disableCopy: "disable-copy",
disablePaste: "disable-paste",
swapRedBlue: "swap-red-blue",
readOnly: "read-only",
recordingPath: "recording-path",
recordingName: "recording-name",
createRecordingPath: "create-recording-path",
recordingExcludeOutput: "recording-exclude-output",
recordingExcludeMouse: "recording-exclude-mouse",
recordingIncludeKeys: "recording-include-keys",
wolSendPacket: "wol-send-packet",
wolMacAddr: "wol-mac-addr",
wolBroadcastAddr: "wol-broadcast-addr",
wolUdpPort: "wol-udp-port",
wolWaitTime: "wol-wait-time",
};
for (const [key, value] of Object.entries(config)) {
if (value !== undefined && value !== null && value !== "") {
const paramName = mappings[key] || key;
// Guacamole expects boolean values as strings "true" or "false"
if (typeof value === "boolean") {
params[paramName] = value ? "true" : "false";
} else {
params[paramName] = value;
}
}
}
return params;
}
export async function getGuacamoleToken(
request: GuacamoleTokenRequest,
): Promise<GuacamoleTokenResponse> {
try {
// Convert guacamoleConfig to guacamole parameter format
const guacParams = toGuacamoleParams(request.guacamoleConfig);
// Debug: log guacamoleConfig and converted params
console.log("[Guacamole] Request guacamoleConfig:", request.guacamoleConfig);
console.log("[Guacamole] Converted params:", guacParams);
console.log("[Guacamole] Param count:", Object.keys(guacParams).length);
// Use authApi (port 30001 without /ssh prefix) since guacamole routes are at /guacamole
const response = await authApi.post("/guacamole/token", {
type: request.protocol,
hostname: request.hostname,
port: request.port,
username: request.username,
password: request.password,
domain: request.domain,
security: request.security,
"ignore-cert": request.ignoreCert,
...guacParams,
});
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token");
}
}