feat: RDP backend — Guacamole TCP tunnel to guacd over WebSocket

- 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 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-12 17:27:09 -04:00
parent c8868258d5
commit 5d75869bb4
4 changed files with 309 additions and 0 deletions

View File

@ -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/(.*)'],

View File

@ -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: "<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);
// 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<string, string> = {
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: "<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.
* 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;
}
}

View File

@ -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<any, net.Socket>();
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));
}
}
}

View File

@ -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 {}