import GuacamoleLite from "guacamole-lite"; import { guacLogger } from "../../../src/backend/utils/logger.js"; import { GuacamoleTokenService, type GuacamoleRecordingMetadata, } from "./token-service.js"; import { createCurrentSessionRecordingRepository, getCurrentSettingValue, } from "../../../src/backend/database/repositories/factory.js"; import { resolveGuacdOptions } from "../../../src/backend/utils/guacd-config.js"; import fs from "fs"; import path from "path"; const tokenService = GuacamoleTokenService.getInstance(); function readGuacdOptions(): { host: string; port: number } { let dbUrl: string | undefined; try { dbUrl = getCurrentSettingValue("guac_url") ?? undefined; } catch { // DB not available yet, use env var defaults } return resolveGuacdOptions(dbUrl); } export const GUAC_WS_PORT = 30008; const DATA_DIR = process.env.DATA_DIR || "./db/data"; const GUACAMOLE_RECORDINGS_DIR = process.env.GUACD_RECORDING_BACKEND_PATH || path.join(DATA_DIR, "session_recordings", "guacamole"); type GuacamoleClientConnection = { guacamoleConnectionId?: string; connectionSettings?: { connection?: { type?: string; join?: string; readOnly?: boolean }; recording?: GuacamoleRecordingMetadata; termixMeta?: { termixConnectId: string; hostId: number; ownerUserId: string; protocol: string; }; }; }; export interface GuacSessionInfo { guacamoleConnectionId: string; hostId: number; ownerUserId: string; protocol: string; openedAt: number; } // Keyed by termixConnectId (routes.ts's correlation id), populated once the // primary connection's guacd handshake completes. const guacSessionByConnectId = new Map(); // Keyed by guacd's own guacamoleConnectionId, for join-time lookups. const guacSessionByGuacamoleId = new Map(); export function getGuacSessionByConnectId( connectId: string, userId: string, ): GuacSessionInfo | null { const info = guacSessionByConnectId.get(connectId); return info?.ownerUserId === userId ? info : null; } export function getGuacSessionInfo( guacamoleConnectionId: string, ): GuacSessionInfo | null { return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null; } async function persistGuacamoleRecording( clientConnection: GuacamoleClientConnection, ): Promise { const recording = clientConnection.connectionSettings?.recording; if (!recording) return; const resolvedPath = path.resolve(GUACAMOLE_RECORDINGS_DIR, recording.path); const allowedBase = `${path.resolve(GUACAMOLE_RECORDINGS_DIR)}${path.sep}`; if (!resolvedPath.startsWith(allowedBase)) return; // guacd may flush/rename the recording just after the websocket closes. for ( let attempt = 0; attempt < 10 && !fs.existsSync(resolvedPath); attempt++ ) { await new Promise((resolve) => setTimeout(resolve, 100)); } if (!fs.existsSync(resolvedPath)) { const guacdPath = recording.guacdPath ?? GUACAMOLE_RECORDINGS_DIR; guacLogger.warn("Guacamole recording file was not found", { operation: "guac_recording_missing", hostId: recording.hostId, path: resolvedPath, guacdPath, hint: "guacd writes the recording to guacdPath, the backend reads it from path. " + "When guacd runs in its own container these must be the same volume — set " + "GUACD_RECORDING_PATH to guacd's mount point and GUACD_RECORDING_BACKEND_PATH to this one.", }); return; } const endedAt = new Date(); const startedAt = new Date(recording.startedAt); await createCurrentSessionRecordingRepository().create({ hostId: recording.hostId, userId: recording.userId, startedAt: startedAt.toISOString(), endedAt: endedAt.toISOString(), duration: Math.max( 0, Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000), ), recordingPath: resolvedPath, protocol: recording.protocol, format: "guacamole", }); } const websocketOptions = { host: "127.0.0.1", port: GUAC_WS_PORT, }; const clientOptions = { crypt: { cypher: "AES-256-CBC", key: tokenService.getEncryptionKey(), }, log: { level: "ERRORS", stdLog: (...args: unknown[]) => { guacLogger.info(args.join(" ")); }, errorLog: (...args: unknown[]) => { guacLogger.error(args.join(" ")); }, }, allowedUnencryptedConnectionSettings: { rdp: ["width", "height", "dpi"], vnc: ["width", "height"], 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, audio: ["audio/L16"], }, vnc: { "swap-red-blue": false, cursor: "remote", width: 1280, height: 720, // macOS Screen Sharing negotiates its VNC security type over several // round trips (RFB type 30 -> 33/36/2/35) and can fail the first // attempt; retrying lets guacd's VNC client re-establish instead of // guacd giving up immediately (Support#1063). autoretry: 2, }, telnet: { "terminal-type": "xterm-256color", }, }, }; const _origConsoleLog = console.log; console.log = (...args: unknown[]) => { const msg = args[0]; if (typeof msg === "string" && msg.startsWith("New client connection")) return; _origConsoleLog(...args); }; function createGuacServer(): GuacamoleLite { const guacdOptions = readGuacdOptions(); const server = new GuacamoleLite( websocketOptions, guacdOptions, clientOptions, ); server.on("open", (clientConnection: GuacamoleClientConnection) => { guacLogger.info("Guacamole connection opened", { operation: "guac_connection_open", type: clientConnection.connectionSettings?.connection?.type, }); const termixMeta = clientConnection.connectionSettings?.termixMeta; const guacamoleConnectionId = clientConnection.guacamoleConnectionId; const isJoin = !!clientConnection.connectionSettings?.connection?.join; if (!isJoin && termixMeta && guacamoleConnectionId) { const info: GuacSessionInfo = { guacamoleConnectionId, hostId: termixMeta.hostId, ownerUserId: termixMeta.ownerUserId, protocol: termixMeta.protocol, openedAt: Date.now(), }; guacSessionByConnectId.set(termixMeta.termixConnectId, info); guacSessionByGuacamoleId.set(guacamoleConnectionId, info); } }); server.on("close", (clientConnection: GuacamoleClientConnection) => { guacLogger.info("Guacamole connection closed", { operation: "guac_connection_close", type: clientConnection.connectionSettings?.connection?.type, }); const isJoin = !!clientConnection.connectionSettings?.connection?.join; const termixMeta = clientConnection.connectionSettings?.termixMeta; const guacamoleConnectionId = clientConnection.guacamoleConnectionId; if (!isJoin && termixMeta && guacamoleConnectionId) { guacSessionByConnectId.delete(termixMeta.termixConnectId); guacSessionByGuacamoleId.delete(guacamoleConnectionId); } persistGuacamoleRecording(clientConnection).catch((error) => { guacLogger.error("Failed to persist Guacamole recording", error, { operation: "guac_recording_persist_error", }); }); }); server.on( "error", (clientConnection: GuacamoleClientConnection, error: Error) => { guacLogger.error("Guacamole connection error", error, { operation: "guac_connection_error", type: clientConnection.connectionSettings?.connection?.type, }); }, ); return server; } let guacServer: GuacamoleLite | null = null; /** * Starts the guacamole-lite WebSocket server. Called from this plugin's * activate() -- see plugins/remote-desktop/backend/index.mjs. */ export async function startGuacamoleService(): Promise { guacServer = createGuacServer(); } /** * Stops the guacamole-lite WebSocket server. Called from this plugin's * deactivate(). */ export async function stopGuacamoleService(): Promise { if (!guacServer) return; try { guacServer.close(); } catch (err) { guacLogger.error("Error closing guac server during shutdown", err as Error); } guacServer = null; } export async function restartGuacServer(): Promise { // A no-op while the plugin is disabled: creating a new server here would // silently resurrect the WS server behind deactivate()'s back. if (!guacServer) return; try { guacServer.close(); } catch (err) { guacLogger.error("Error closing guac server during restart", err as Error); } guacServer = createGuacServer(); } export { guacServer, tokenService };