import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; import express from "express"; import { GuacamoleTokenService } from "./token-service.js"; import { withRecordingSettings } from "./recording-settings.js"; import { withDriveSettings } from "./drive-settings.js"; import { guacLogger } from "../../../src/backend/utils/logger.js"; import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; import { PermissionManager } from "../../../src/backend/utils/permission-manager.js"; import { resolveRecipientSharedHostAuthentication, type RecipientSharedHostAuthResolution, } from "../../../src/backend/utils/shared-host-auth-resolver.js"; import type { AuthOverrideProtocol } from "../../../src/types/auth-protocols.js"; import net from "net"; import crypto from "crypto"; import path from "path"; import type { AuthenticatedRequest } from "../../../src/types/index.js"; import { createCurrentHostResolutionRepository, createCurrentSettingsRepository, } from "../../../src/backend/database/repositories/factory.js"; import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js"; import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js"; import { getGuacSessionByConnectId } from "./guacamole-server.js"; import { logAudit, getAuditUsername, getRequestMeta, } from "../../../src/backend/utils/audit-logger.js"; import { resolveJumpTunnelEndpoint } from "./jump-tunnel-endpoint.js"; import { buildRdpSettings, resolveRdpAuthTypeForConnect, resolveRdpDomain, } from "./rdp-settings.js"; import { createMacosVncCompatibilityProxy } from "./macos-vnc-proxy.js"; const router = express.Router(); const tokenService = GuacamoleTokenService.getInstance(); const authManager = AuthManager.getInstance(); const DATA_DIR = process.env.DATA_DIR || "./db/data"; router.use(authManager.createAuthMiddleware()); router.get("/connection/:connectId", (req: AuthenticatedRequest, res) => { if (!req.userId) return res.status(401).json({ error: "Authentication required" }); const session = getGuacSessionByConnectId( String(req.params.connectId), req.userId, ); res.json({ guacamoleConnectionId: session?.guacamoleConnectionId ?? null }); }); /** * @openapi * /guacamole/token: * post: * summary: Generate an encrypted Guacamole connection token * description: Creates an AES-256-CBC encrypted token for guacamole-lite with the given connection parameters * tags: * - Guacamole * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: * - type * - hostname * properties: * type: * type: string * enum: [rdp, vnc, telnet] * hostname: * type: string * port: * type: integer * username: * type: string * password: * type: string * domain: * type: string * responses: * 200: * description: Encrypted connection token * content: * application/json: * schema: * type: object * properties: * token: * type: string * 400: * description: Invalid request * 500: * description: Server error */ router.post("/token", async (req, res) => { try { const { type, hostname, port, username, password, domain, ...rawOptions } = req.body; // Strip "auto" sentinel values before forwarding to guacd const options: Record = {}; for (const [key, value] of Object.entries(rawOptions)) { if (value !== "auto") options[key] = value; } 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, username || undefined, password, { port: port || 5900, ...options, }, ); break; case "telnet": token = tokenService.createTelnetToken(hostname, username, password, { port: port || 23, ...options, }); break; default: return res.status(400).json({ error: "Invalid connection type" }); } res.json({ token }); } catch (error) { guacLogger.error("Failed to generate guacamole token", error, { operation: "guac_token_error", }); res.status(500).json({ error: "Failed to generate connection token" }); } }); /** * @openapi * /guacamole/connect-host/{hostId}: * post: * summary: Generate Guacamole connection token from host configuration * description: Fetches host configuration from database and generates a connection token for RDP/VNC/Telnet * tags: * - Guacamole * security: * - bearerAuth: [] * parameters: * - in: path * name: hostId * required: true * schema: * type: integer * description: Host ID to connect to * requestBody: * required: false * content: * application/json: * schema: * type: object * properties: * protocol: * type: string * enum: [rdp, vnc, telnet] * description: Override the host's default connection type * promptedUsername: * type: string * description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted. * promptedPassword: * type: string * description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted. * responses: * 200: * description: Connection token generated successfully * content: * application/json: * schema: * type: object * properties: * token: * type: string * description: Encrypted connection token * guacamoleConnectionId: * type: string * nullable: true * description: Null until the WebSocket handshake completes. Query /guacamole/connection/{connectId} for the session-sharing ID. * termixConnectId: * type: string * description: Correlation ID for looking up this connection after the WebSocket opens. * 400: * description: Invalid request or unsupported connection type * 403: * description: Access denied to host * 404: * description: Host not found * 500: * description: Server error */ router.post( "/connect-host/:hostId", async (req: express.Request, res: express.Response) => { try { const userId = (req as AuthenticatedRequest).userId!; const hostId = Number.parseInt(String(req.params.hostId), 10); if (!hostId || isNaN(hostId)) { return res.status(400).json({ error: "Invalid host ID" }); } const hostResolutionRepository = createCurrentHostResolutionRepository(); // Decrypt under the owner's DEK; shared hosts carry owner-encrypted fields. const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId); const host = hostOwnerId ? await hostResolutionRepository.findHostById(hostId, hostOwnerId) : null; if (!host) { return res.status(404).json({ error: "Host not found" }); } if (host.userId !== userId) { const permissionManager = PermissionManager.getInstance(); const accessInfo = await permissionManager.canAccessHost( userId, hostId, "connect", ); if (!accessInfo.hasAccess) { guacLogger.warn("User attempted to access host without permission", { operation: "guac_access_denied", userId, hostId, }); return res.status(403).json({ error: "Access denied to this host" }); } } const requestedProtocol = req.body?.protocol as string | undefined; const connectionType = requestedProtocol || (host.connectionType as string); if (!["rdp", "vnc", "telnet"].includes(connectionType)) { return res.status(400).json({ error: `Connection type '${connectionType}' is not supported for remote desktop. Only RDP, VNC, and Telnet are supported.`, }); } // Old hosts only had connectionType set; enableRdp/enableVnc/enableTelnet defaulted to false. // Apply the same migration fallback used in host.ts GET routes. const ct = host.connectionType as string; const rdpRaw = !!host.enableRdp; const vncRaw = !!host.enableVnc; const telRaw = !!host.enableTelnet; const isMigratedNonSsh = !rdpRaw && !vncRaw && !telRaw && ct && ct !== "ssh"; const protocolEnabledMap: Record = { rdp: isMigratedNonSsh ? ct === "rdp" : rdpRaw, vnc: isMigratedNonSsh ? ct === "vnc" : vncRaw, telnet: isMigratedNonSsh ? ct === "telnet" : telRaw, }; if (!protocolEnabledMap[connectionType]) { return res.status(400).json({ error: `${connectionType.toUpperCase()} is not enabled for this host.`, }); } let guacConfig: Record = {}; if (host.guacamoleConfig) { try { guacConfig = typeof host.guacamoleConfig === "string" ? JSON.parse(host.guacamoleConfig as string) : (host.guacamoleConfig as Record); } catch (error) { guacLogger.warn("Failed to parse guacamole config", { operation: "guac_config_parse_error", hostId, error: getErrorMessage(error), }); } } // Strip "auto" sentinel values — these mean "use guacd default" in the UI // but guacd doesn't recognise "auto" as a valid parameter value. for (const key of Object.keys(guacConfig)) { if (guacConfig[key] === "auto") { delete guacConfig[key]; } } // Extract per-connection guacd proxy settings before passing the rest as connection settings const perConnectionGuacdHost = guacConfig["guacd-hostname"] as string | undefined; const perConnectionGuacdPortRaw = guacConfig["guacd-port"]; const perConnectionGuacdPort = perConnectionGuacdPortRaw ? parseInt(String(perConnectionGuacdPortRaw), 10) || undefined : undefined; delete guacConfig["guacd-hostname"]; delete guacConfig["guacd-port"]; if (guacConfig.dpi != null) { const parsed = parseInt(String(guacConfig.dpi), 10); guacConfig.dpi = Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } const hostRecord = host as Record; const hostRepository = hostResolutionRepository; const isSharedConnection = host.userId !== userId; let sharedAuthResolution: RecipientSharedHostAuthResolution | null = null; if (isSharedConnection) { // Recipients never read the owner's raw secrets; wipe them and use // the per-recipient snapshot for the requested protocol instead. host.password = null; host.rdpUser = null; host.rdpPassword = null; host.vncUser = null; host.vncPassword = null; host.telnetUser = null; host.telnetPassword = null; try { const resolution = await resolveRecipientSharedHostAuthentication( host, hostId, userId, connectionType as AuthOverrideProtocol, ); sharedAuthResolution = resolution; const auth = resolution.source === "personal-override" ? { ...resolution.credential, domain: null } : resolution.source === "owner-shared" ? resolution.secret : null; if (auth) { if (connectionType === "rdp") { host.rdpUser = auth.username ?? null; host.rdpPassword = auth.password ?? null; if (auth.domain) host.rdpDomain = auth.domain; } else if (connectionType === "vnc") { host.vncUser = auth.username ?? null; host.vncPassword = auth.password ?? null; } else if (connectionType === "telnet") { host.telnetUser = auth.username ?? null; host.telnetPassword = auth.password ?? null; } } } catch (e) { guacLogger.warn("Failed to resolve shared host auth", { operation: "guac_shared_auth_resolve", hostId, protocol: connectionType, error: getErrorMessage(e, "Unknown"), }); } } else { // Backward compat: if authType is not stored but a credentialId is, treat as credential mode const rdpEffectiveAuthType = (host.rdpAuthType as string) || (host.rdpCredentialId ? "credential" : "direct"); const vncEffectiveAuthType = (host.vncAuthType as string) || (host.vncCredentialId ? "credential" : "direct"); const telnetEffectiveAuthType = (host.telnetAuthType as string) || (hostRecord.telnetCredentialId ? "credential" : "direct"); if (rdpEffectiveAuthType === "credential" && host.rdpCredentialId) { try { const cred = await hostRepository.findCredentialByIdForUser( host.rdpCredentialId as number, host.userId as string, ); if (cred) { if (cred.username) host.rdpUser = cred.username; if (cred.password) host.rdpPassword = cred.password; // domain is never sourced from credential } } catch (e) { guacLogger.warn("Failed to resolve RDP credential", { operation: "guac_rdp_credential_resolve", hostId, error: getErrorMessage(e, "Unknown"), }); } } if (vncEffectiveAuthType === "credential" && host.vncCredentialId) { try { const cred = await hostRepository.findCredentialByIdForUser( host.vncCredentialId as number, host.userId as string, ); if (cred) { if (cred.password) host.vncPassword = cred.password; if (cred.username) host.vncUser = cred.username; } } catch (e) { guacLogger.warn("Failed to resolve VNC credential", { operation: "guac_vnc_credential_resolve", hostId, error: getErrorMessage(e, "Unknown"), }); } } if ( telnetEffectiveAuthType === "credential" && hostRecord.telnetCredentialId ) { try { const cred = await hostRepository.findCredentialByIdForUser( hostRecord.telnetCredentialId as number, host.userId as string, ); if (cred) { if (cred.username) host.telnetUser = cred.username; if (cred.password) host.telnetPassword = cred.password; } } catch (e) { guacLogger.warn("Failed to resolve Telnet credential", { operation: "guac_telnet_credential_resolve", hostId, error: getErrorMessage(e, "Unknown"), }); } } } let token: string; let hostname = host.ip as string; let port = host.port as number; let username: string; let password: string; const rdpAuthTypeForConnect = resolveRdpAuthTypeForConnect({ storedAuthType: host.rdpAuthType as string | null, credentialId: host.rdpCredentialId as number | null, sharedResolution: isSharedConnection ? sharedAuthResolution : undefined, }); switch (connectionType) { case "rdp": if (rdpAuthTypeForConnect === "none") { username = String(req.body?.promptedUsername || ""); password = String(req.body?.promptedPassword || ""); } else { username = (host.rdpUser as string) || (host.username as string) || ""; password = (host.rdpPassword as string) || (host.password as string) || ""; } port = (host.rdpPort as number) || port || 3389; break; case "vnc": username = (host.vncUser as string) || ""; password = (host.vncPassword as string) || (host.password as string) || ""; port = (host.vncPort as number) || port || 5900; break; case "telnet": username = (host.telnetUser as string) || ""; password = (host.telnetPassword as string) || (host.password as string) || ""; port = (host.telnetPort as number) || port || 23; break; default: username = ""; password = ""; } const storedDomain = (host.rdpDomain as string) || (host.domain as string) || ""; const domain = resolveRdpDomain( rdpAuthTypeForConnect, req.body?.promptedDomain, storedDomain, ); // Establish SSH tunnel if jump hosts are configured let jumpHosts: Array<{ hostId: number }> = []; if (host.jumpHosts) { try { jumpHosts = typeof host.jumpHosts === "string" ? JSON.parse(host.jumpHosts as string) : (host.jumpHosts as Array<{ hostId: number }>); } catch { jumpHosts = []; } } let tunnelEndpoint: ReturnType | null = null; if (jumpHosts.length > 0 || (connectionType === "vnc" && !username)) { let guacdUrl: string | undefined; try { guacdUrl = (await createCurrentSettingsRepository().get("guac_url")) ?? undefined; } catch { // Environment/default guacd configuration remains available. } const guacdHost = perConnectionGuacdHost || resolveGuacdOptions(guacdUrl).host; tunnelEndpoint = resolveJumpTunnelEndpoint(guacdHost); } if (jumpHosts.length > 0) { try { // The chain dials the first hop through that hop's own SOCKS5 // settings; the target host's proxy config does not apply to it. const jumpClient = await createJumpHostChain(jumpHosts, userId); if (!jumpClient) { guacLogger.error( "Failed to establish jump host chain for guacamole", undefined, { operation: "guac_ssh_tunnel_error", hostId }, ); return res.status(500).json({ error: "Failed to establish SSH tunnel to remote host", }); } const targetHostname = hostname; const targetPort = port; const tunnelPort = await new Promise((resolve, reject) => { const server = net.createServer((sock) => { jumpClient.forwardOut( "127.0.0.1", 0, targetHostname, targetPort, (err, stream) => { if (err) { sock.destroy(); return; } sock.pipe(stream).pipe(sock); }, ); }); server.on("error", reject); server.listen(0, tunnelEndpoint!.bindHost, () => { const addr = server.address() as net.AddressInfo; // Auto-cleanup after 1 hour setTimeout( () => { server.close(); jumpClient.end(); }, 60 * 60 * 1000, ); resolve(addr.port); }); }); hostname = tunnelEndpoint!.advertisedHost; port = tunnelPort; guacLogger.info("SSH tunnel established for guacamole", { operation: "guac_ssh_tunnel", hostId, tunnelPort, }); } catch (tunnelError) { guacLogger.error("Failed to establish SSH tunnel", tunnelError, { operation: "guac_ssh_tunnel_error", hostId, }); return res.status(500).json({ error: "Failed to establish SSH tunnel to remote host", }); } } if (connectionType === "vnc" && !username) { try { const proxy = await createMacosVncCompatibilityProxy({ targetHost: hostname, targetPort: port, bindHost: tunnelEndpoint!.bindHost, }); hostname = tunnelEndpoint!.advertisedHost; port = proxy.port; setTimeout(proxy.close, 60 * 60 * 1000).unref(); guacLogger.info("VNC compatibility proxy established", { operation: "guac_vnc_compatibility_proxy", hostId, proxyPort: port, }); } catch (proxyError) { guacLogger.error( "Failed to establish VNC compatibility proxy", proxyError, { operation: "guac_vnc_compatibility_proxy_error", hostId, }, ); return res.status(500).json({ error: "Failed to establish VNC compatibility proxy", }); } } const guacdOverrides = { ...(perConnectionGuacdHost ? { guacdHost: perConnectionGuacdHost } : {}), ...(perConnectionGuacdPort ? { guacdPort: perConnectionGuacdPort } : {}), }; const recordingEnabled = connectionType !== "vnc" && host.enableSessionLogging !== false; const recordingName = `${crypto.randomUUID()}.guac`; const recordingPath = process.env.GUACD_RECORDING_PATH || process.env.GUACD_RECORDING_BACKEND_PATH || path.resolve(DATA_DIR, "session_recordings", "guacamole"); const recordingMetadata = recordingEnabled ? { hostId, userId, protocol: connectionType as "rdp" | "vnc" | "telnet", path: recordingName, guacdPath: recordingPath, startedAt: new Date().toISOString(), } : undefined; if (recordingEnabled) { guacConfig = withRecordingSettings( guacConfig, recordingPath, recordingName, ); } const termixConnectId = crypto.randomUUID(); const termixMeta = { termixConnectId, hostId, ownerUserId: userId, protocol: connectionType as "rdp" | "vnc" | "telnet", }; switch (connectionType) { case "rdp": guacConfig = withDriveSettings(guacConfig, userId); token = tokenService.createRdpToken( hostname, username, password, buildRdpSettings({ port, domain, security: (host.rdpSecurity as string) || (host.security as string) || undefined, ignoreCert: host.rdpIgnoreCert !== undefined ? !!host.rdpIgnoreCert : host.ignoreCert !== undefined ? !!host.ignoreCert : true, guacConfig, guacdOverrides, }), recordingMetadata, termixMeta, ); break; case "vnc": token = tokenService.createVncToken( hostname, username || undefined, password, { port, ...guacConfig, ...guacdOverrides, }, recordingMetadata, termixMeta, ); break; case "telnet": token = tokenService.createTelnetToken( hostname, username, password, { port, ...guacConfig, ...guacdOverrides, }, recordingMetadata, termixMeta, ); break; default: return res.status(400).json({ error: "Invalid connection type" }); } const { ipAddress, userAgent } = getRequestMeta(req); await logAudit({ userId, username: await getAuditUsername(userId), action: `${connectionType}_connect`, resourceType: "host", resourceId: String(hostId), resourceName: `${hostname}:${port}`, ipAddress, userAgent, success: true, }); res.json({ token, termixConnectId, guacamoleConnectionId: null, }); } catch (error) { guacLogger.error("Failed to generate guacamole token for host", error, { operation: "guac_host_token_error", }); res.status(500).json({ error: "Failed to generate connection token" }); } }, ); /** * GET /guacamole/status * Check if guacd is reachable */ router.get("/status", async (req, res) => { try { let dbUrl: string | undefined; try { dbUrl = (await createCurrentSettingsRepository().get("guac_url")) ?? undefined; } catch { // Fall back to env vars } const { host: guacdHost, port: guacdPort } = resolveGuacdOptions(dbUrl); const net = await import("net"); const checkConnection = (): Promise => { return new Promise((resolve) => { const socket = new net.Socket(); socket.setTimeout(3000); socket.on("connect", () => { socket.destroy(); resolve(true); }); socket.on("timeout", () => { socket.destroy(); resolve(false); }); socket.on("error", () => { socket.destroy(); resolve(false); }); socket.connect(guacdPort, guacdHost); }); }; const isConnected = await checkConnection(); res.json({ guacd: { host: guacdHost, port: guacdPort, status: isConnected ? "connected" : "disconnected", }, websocket: { port: 30008, status: "running", }, }); } catch (error) { guacLogger.error("Failed to check guacamole status", error, { operation: "guac_status_error", }); res.status(500).json({ error: "Failed to check status" }); } }); export { router };