import GuacamoleLite from "guacamole-lite"; import { guacLogger } from "../utils/logger.js"; import { GuacamoleTokenService } from "./token-service.js"; import { getDb } from "../database/db/index.js"; import { resolveGuacdOptions } from "../utils/guacd-config.js"; import fs from "fs"; import path from "path"; import { sessionRecordings } from "../database/db/schema.js"; import type { GuacamoleRecordingMetadata } from "./token-service.js"; const tokenService = GuacamoleTokenService.getInstance(); function readGuacdOptions(): { host: string; port: number } { let dbUrl: string | undefined; try { const db = getDb(); const urlRow = db.$client .prepare("SELECT value FROM settings WHERE key = 'guac_url'") .get() as { value: string } | undefined; dbUrl = urlRow?.value; } catch { // DB not available yet, use env var defaults } return resolveGuacdOptions(dbUrl); } 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 = { connectionSettings?: { connection?: { type?: string }; recording?: GuacamoleRecordingMetadata; }; }; 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)) { guacLogger.warn("Guacamole recording file was not found", { operation: "guac_recording_missing", hostId: recording.hostId, path: resolvedPath, }); return; } const endedAt = new Date(); const startedAt = new Date(recording.startedAt); await getDb() .insert(sessionRecordings) .values({ 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 = { 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"], 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", security: "any", width: 1280, height: 720, }, 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, }); }); server.on("close", (clientConnection: GuacamoleClientConnection) => { guacLogger.info("Guacamole connection closed", { operation: "guac_connection_close", type: clientConnection.connectionSettings?.connection?.type, }); 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 = createGuacServer(); export async function restartGuacServer(): Promise { try { guacServer.close(); } catch (err) { guacLogger.error("Error closing guac server during restart", err as Error); } guacServer = createGuacServer(); } export { guacServer, tokenService };