wraith/backend/src/rdp/guacamole.service.ts

216 lines
6.7 KiB
TypeScript

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: "<length>.<value>"
*/
@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<net.Socket> {
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<string, string> = {
'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: "<len>.<value>"
* 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;
}
}