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:
parent
c8868258d5
commit
5d75869bb4
@ -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/(.*)'],
|
||||
|
||||
163
backend/src/rdp/guacamole.service.ts
Normal file
163
backend/src/rdp/guacamole.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
132
backend/src/rdp/rdp.gateway.ts
Normal file
132
backend/src/rdp/rdp.gateway.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
12
backend/src/rdp/rdp.module.ts
Normal file
12
backend/src/rdp/rdp.module.ts
Normal 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 {}
|
||||
Loading…
Reference in New Issue
Block a user