import { Injectable, Logger } from '@nestjs/common'; import * as net from 'net'; /** * Guacamole wire protocol: instructions are comma-separated length-prefixed fields * terminated by semicolons. * Example: "4.size,4.1024,3.768;" * Format per field: "." */ @Injectable() export class GuacamoleService { private readonly logger = new Logger(GuacamoleService.name); private readonly host = process.env.GUACD_HOST || 'guacd'; private readonly port = parseInt(process.env.GUACD_PORT || '4822', 10); /** * Opens a raw TCP connection to guacd, completes the SELECT → CONNECT handshake, * and returns the live socket ready for bidirectional Guacamole instruction traffic. */ async connect(params: { hostname: string; port: number; username: string; password: string; domain?: string; width: number; height: number; dpi?: number; security?: string; colorDepth?: number; ignoreCert?: boolean; }): Promise { return new Promise((resolve, reject) => { const socket = net.createConnection(this.port, this.host, () => { this.logger.log(`Connected to guacd at ${this.host}:${this.port}`); // Phase 1: SELECT rdp — tells guacd which protocol to prepare socket.write(this.encode('select', 'rdp')); let buffer = ''; const onHandshake = (data: Buffer) => { buffer += data.toString('utf-8'); // guacd responds with "args" listing the parameters it expects. // Wait until we receive at least one complete instruction (ends with ';'). const semicolonIdx = buffer.indexOf(';'); if (semicolonIdx === -1) return; // We've received the args instruction — remove handshake listener socket.removeListener('data', onHandshake); // Clear the connect timeout — handshake completed socket.setTimeout(0); // Parse the args instruction to get expected parameter names const argsInstruction = buffer.substring(0, semicolonIdx + 1); const argNames = this.decode(argsInstruction); // First element is the opcode ("args"), rest are parameter names if (argNames[0] === 'args') { argNames.shift(); } this.logger.log(`guacd expects ${argNames.length} args: ${argNames.join(', ')}`); // Phase 2: CONNECT — send values in the exact order guacd expects const connectInstruction = this.buildConnectInstruction(params, argNames); this.logger.debug(`Sending connect instruction with ${argNames.length} values`); socket.write(connectInstruction); resolve(socket); }; socket.on('data', onHandshake); }); socket.on('error', (err) => { this.logger.error(`guacd connection error: ${err.message}`); reject(err); }); // 10-second timeout for the SELECT → args handshake socket.setTimeout(10000, () => { socket.destroy(); reject(new Error(`guacd handshake timeout connecting to ${this.host}:${this.port}`)); }); }); } private buildConnectInstruction( params: { hostname: string; port: number; username: string; password: string; domain?: string; width: number; height: number; dpi?: number; security?: string; colorDepth?: number; ignoreCert?: boolean; }, argNames: string[], ): string { // Map our params to guacd's expected arg names const paramMap: Record = { 'hostname': params.hostname, 'port': String(params.port), 'username': params.username, 'password': params.password, 'domain': params.domain || '', 'width': String(params.width), 'height': String(params.height), 'dpi': String(params.dpi || 96), 'security': params.security || 'any', 'color-depth': String(params.colorDepth || 24), 'ignore-cert': params.ignoreCert !== false ? 'true' : 'false', 'disable-audio': 'false', 'enable-wallpaper': 'false', 'enable-theming': 'true', 'enable-font-smoothing': 'true', 'resize-method': 'reconnect', 'server-layout': '', 'timezone': '', 'console': '', 'initial-program': '', 'client-name': 'Wraith', 'enable-full-window-drag': 'false', 'enable-desktop-composition': 'false', 'enable-menu-animations': 'false', 'disable-bitmap-caching': 'false', 'disable-offscreen-caching': 'false', 'disable-glyph-caching': 'false', 'preconnection-id': '', 'preconnection-blob': '', 'enable-sftp': 'false', 'sftp-hostname': '', 'sftp-port': '', 'sftp-username': '', 'sftp-password': '', 'sftp-private-key': '', 'sftp-passphrase': '', 'sftp-directory': '', 'sftp-root-directory': '', 'sftp-server-alive-interval': '', 'recording-path': '', 'recording-name': '', 'recording-exclude-output': '', 'recording-exclude-mouse': '', 'recording-include-keys': '', 'create-recording-path': '', 'remote-app': '', 'remote-app-dir': '', 'remote-app-args': '', 'gateway-hostname': '', 'gateway-port': '', 'gateway-domain': '', 'gateway-username': '', 'gateway-password': '', 'load-balance-info': '', 'normalize-clipboard': '', 'force-lossless': '', 'wol-send-packet': '', 'wol-mac-addr': '', 'wol-broadcast-addr': '', 'wol-udp-port': '', 'wol-wait-time': '', }; // Build values array matching the exact order guacd expects const values = argNames.map((name) => paramMap[name] ?? ''); return this.encode('connect', ...values); } /** * Encode a Guacamole instruction. * Each part is length-prefixed: "." * Parts are comma-separated, instruction ends with ';' * Example: encode('size', '1024', '768') → "4.size,4.1024,3.768;" */ encode(...parts: string[]): string { return parts.map((p) => `${p.length}.${p}`).join(',') + ';'; } /** * Decode a Guacamole instruction string back to a string array. */ decode(instruction: string): string[] { const parts: string[] = []; let pos = 0; // Strip trailing semicolon if present const clean = instruction.endsWith(';') ? instruction.slice(0, -1) : instruction; while (pos < clean.length) { const dotIndex = clean.indexOf('.', pos); if (dotIndex === -1) break; const len = parseInt(clean.substring(pos, dotIndex), 10); if (isNaN(len)) break; const value = clean.substring(dotIndex + 1, dotIndex + 1 + len); parts.push(value); // Move past: value + separator (comma or end) pos = dotIndex + 1 + len + 1; } return parts; } }