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 { ConnectionsModule } from './connections/connections.module';
|
||||||
import { SettingsModule } from './settings/settings.module';
|
import { SettingsModule } from './settings/settings.module';
|
||||||
import { TerminalModule } from './terminal/terminal.module';
|
import { TerminalModule } from './terminal/terminal.module';
|
||||||
|
import { RdpModule } from './rdp/rdp.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -16,6 +17,7 @@ import { TerminalModule } from './terminal/terminal.module';
|
|||||||
ConnectionsModule,
|
ConnectionsModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
TerminalModule,
|
TerminalModule,
|
||||||
|
RdpModule,
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: join(__dirname, '..', 'public'),
|
rootPath: join(__dirname, '..', 'public'),
|
||||||
exclude: ['/api/(.*)'],
|
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