From 5d75869bb4758904d26cd00c63554c879cc9b14e Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Thu, 12 Mar 2026 17:27:09 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20RDP=20backend=20=E2=80=94=20Guacamole?= =?UTF-8?q?=20TCP=20tunnel=20to=20guacd=20over=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guacamole.service.ts: raw TCP client to guacd on GUACD_HOST:GUACD_PORT. Performs SELECT rdp → CONNECT handshake with full RDP parameter set. Provides encode/decode helpers for length-prefixed Guacamole wire format. - rdp.gateway.ts: WebSocket gateway at /ws/rdp. JWT auth via WsAuthGuard. Handles connect (host lookup, credential decrypt, guacd tunnel open) and guac (bidirectional instruction forwarding). Updates lastConnectedAt and creates ConnectionLog on connect (same pattern as SSH gateway). - rdp.module.ts: imports VaultModule, ConnectionsModule, AuthModule. - app.module.ts: registers RdpModule. Co-Authored-By: Claude Opus 4.6 --- backend/src/app.module.ts | 2 + backend/src/rdp/guacamole.service.ts | 163 +++++++++++++++++++++++++++ backend/src/rdp/rdp.gateway.ts | 132 ++++++++++++++++++++++ backend/src/rdp/rdp.module.ts | 12 ++ 4 files changed, 309 insertions(+) create mode 100644 backend/src/rdp/guacamole.service.ts create mode 100644 backend/src/rdp/rdp.gateway.ts create mode 100644 backend/src/rdp/rdp.module.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index bcd589e..61e3a0f 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { VaultModule } from './vault/vault.module'; import { ConnectionsModule } from './connections/connections.module'; import { SettingsModule } from './settings/settings.module'; import { TerminalModule } from './terminal/terminal.module'; +import { RdpModule } from './rdp/rdp.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { TerminalModule } from './terminal/terminal.module'; ConnectionsModule, SettingsModule, TerminalModule, + RdpModule, ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', 'public'), exclude: ['/api/(.*)'], diff --git a/backend/src/rdp/guacamole.service.ts b/backend/src/rdp/guacamole.service.ts new file mode 100644 index 0000000..fafc9d7 --- /dev/null +++ b/backend/src/rdp/guacamole.service.ts @@ -0,0 +1,163 @@ +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); + + // Phase 2: CONNECT with RDP parameters + const connectInstruction = this.buildConnectInstruction(params); + this.logger.debug(`Sending connect instruction to guacd`); + 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; + }): string { + // The connect instruction sends all RDP connection parameters as positional args. + // guacd expects exactly the args it listed in the "args" response to SELECT. + // We send the full standard RDP parameter set. + const args: Record = { + hostname: params.hostname, + port: String(params.port), + username: params.username, + password: params.password, + 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', + }; + + if (params.domain) { + args['domain'] = params.domain; + } + + // Build the connect instruction with opcode + all arg values + const values = Object.values(args); + 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. + * Handles multiple instructions in a single buffer chunk. + */ + 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; + } +} diff --git a/backend/src/rdp/rdp.gateway.ts b/backend/src/rdp/rdp.gateway.ts new file mode 100644 index 0000000..e0ad357 --- /dev/null +++ b/backend/src/rdp/rdp.gateway.ts @@ -0,0 +1,132 @@ +import { + WebSocketGateway, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server } from 'ws'; +import * as net from 'net'; +import { WsAuthGuard } from '../auth/ws-auth.guard'; +import { GuacamoleService } from './guacamole.service'; +import { CredentialsService } from '../vault/credentials.service'; +import { HostsService } from '../connections/hosts.service'; +import { PrismaService } from '../prisma/prisma.service'; + +@WebSocketGateway({ path: '/ws/rdp' }) +export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() server: Server; + private readonly logger = new Logger(RdpGateway.name); + + // Maps browser WebSocket client → live guacd TCP socket + private clientSockets = new Map(); + + constructor( + private guacamole: GuacamoleService, + private credentials: CredentialsService, + private hosts: HostsService, + private prisma: PrismaService, + private wsAuth: WsAuthGuard, + ) {} + + handleConnection(client: any) { + const user = this.wsAuth.validateClient(client); + if (!user) { + client.close(4001, 'Unauthorized'); + return; + } + + this.logger.log(`RDP WS connected: ${user.email}`); + + client.on('message', async (raw: Buffer) => { + try { + const msg = JSON.parse(raw.toString()); + + if (msg.type === 'connect') { + await this.handleConnect(client, msg); + } else if (msg.type === 'guac') { + // Forward raw Guacamole instruction from browser to guacd TCP socket + const socket = this.clientSockets.get(client); + if (socket && !socket.destroyed) { + socket.write(msg.instruction); + } + } + } catch (err: any) { + this.logger.error(`RDP message error: ${err.message}`); + this.send(client, { type: 'error', message: err.message }); + } + }); + } + + handleDisconnect(client: any) { + const socket = this.clientSockets.get(client); + if (socket) { + socket.destroy(); + this.clientSockets.delete(client); + this.logger.log('RDP WS disconnected — guacd socket destroyed'); + } + } + + private async handleConnect(client: any, msg: any) { + const host = await this.hosts.findOne(msg.hostId); + + // Decrypt credentials if attached to host + const cred = host.credentialId + ? await this.credentials.decryptForConnection(host.credentialId) + : null; + + this.logger.log( + `Opening RDP tunnel: ${host.hostname}:${host.port} for host "${host.name}"`, + ); + + const socket = await this.guacamole.connect({ + hostname: host.hostname, + port: host.port, + username: cred?.username || '', + password: cred?.password || '', + domain: cred?.domain || undefined, + width: msg.width || 1920, + height: msg.height || 1080, + dpi: msg.dpi || 96, + security: msg.security || 'any', + colorDepth: msg.colorDepth || 24, + ignoreCert: true, // default permissive — expose as setting in Task 15+ + }); + + this.clientSockets.set(client, socket); + + // Pipe guacd → browser: wrap raw Guacamole instruction bytes in JSON envelope + socket.on('data', (data: Buffer) => { + if (client.readyState === 1 /* WebSocket.OPEN */) { + client.send( + JSON.stringify({ type: 'guac', instruction: data.toString('utf-8') }), + ); + } + }); + + socket.on('close', () => { + this.logger.log(`guacd socket closed for host ${host.id}`); + this.send(client, { type: 'disconnected', reason: 'RDP session closed' }); + this.clientSockets.delete(client); + }); + + socket.on('error', (err) => { + this.logger.error(`guacd socket error for host ${host.id}: ${err.message}`); + this.send(client, { type: 'error', message: err.message }); + }); + + // Connection tracking — same pattern as SSH gateway + this.hosts.touchLastConnected(host.id).catch(() => {}); + this.prisma.connectionLog + .create({ data: { hostId: host.id, protocol: 'rdp' } }) + .catch(() => {}); + + this.send(client, { type: 'connected', hostId: host.id, hostName: host.name }); + } + + private send(client: any, data: any) { + if (client.readyState === 1 /* WebSocket.OPEN */) { + client.send(JSON.stringify(data)); + } + } +} diff --git a/backend/src/rdp/rdp.module.ts b/backend/src/rdp/rdp.module.ts new file mode 100644 index 0000000..aab00f6 --- /dev/null +++ b/backend/src/rdp/rdp.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { GuacamoleService } from './guacamole.service'; +import { RdpGateway } from './rdp.gateway'; +import { VaultModule } from '../vault/vault.module'; +import { ConnectionsModule } from '../connections/connections.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [VaultModule, ConnectionsModule, AuthModule], + providers: [GuacamoleService, RdpGateway], +}) +export class RdpModule {}