fix(rdp): convert to manual ws.Server, fix URL path, fix double session

This commit is contained in:
Vantz Stockwell 2026-03-14 02:40:37 -04:00
parent 3b5e5e0d36
commit f124d4b7d2
5 changed files with 51 additions and 29 deletions

View File

@ -5,6 +5,7 @@ import { AppModule } from './app.module';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { TerminalGateway } from './terminal/terminal.gateway'; import { TerminalGateway } from './terminal/terminal.gateway';
import { SftpGateway } from './terminal/sftp.gateway'; import { SftpGateway } from './terminal/sftp.gateway';
import { RdpGateway } from './rdp/rdp.gateway';
// Crash handlers — catch whatever is killing the process // Crash handlers — catch whatever is killing the process
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
@ -30,9 +31,11 @@ async function bootstrap() {
const server = app.getHttpServer(); const server = app.getHttpServer();
const terminalGateway = app.get(TerminalGateway); const terminalGateway = app.get(TerminalGateway);
const sftpGateway = app.get(SftpGateway); const sftpGateway = app.get(SftpGateway);
const rdpGateway = app.get(RdpGateway);
const terminalWss = new WebSocketServer({ noServer: true }); const terminalWss = new WebSocketServer({ noServer: true });
const sftpWss = new WebSocketServer({ noServer: true }); const sftpWss = new WebSocketServer({ noServer: true });
const rdpWss = new WebSocketServer({ noServer: true });
terminalWss.on('connection', (ws, req) => { terminalWss.on('connection', (ws, req) => {
try { try {
@ -52,6 +55,15 @@ async function bootstrap() {
} }
}); });
rdpWss.on('connection', (ws, req) => {
try {
console.log(`[WS] RDP connection established`);
rdpGateway.handleConnection(ws, req);
} catch (err: any) {
console.error(`[FATAL] RDP handleConnection crashed: ${err.message}\n${err.stack}`);
}
});
// Remove ALL existing upgrade listeners (WsAdapter's) so we handle upgrades first // Remove ALL existing upgrade listeners (WsAdapter's) so we handle upgrades first
const existingListeners = server.listeners('upgrade'); const existingListeners = server.listeners('upgrade');
server.removeAllListeners('upgrade'); server.removeAllListeners('upgrade');
@ -72,8 +84,13 @@ async function bootstrap() {
console.log(`[WS] SFTP upgrade complete`); console.log(`[WS] SFTP upgrade complete`);
sftpWss.emit('connection', ws, req); sftpWss.emit('connection', ws, req);
}); });
} else if (pathname === '/api/ws/rdp') {
rdpWss.handleUpgrade(req, socket, head, (ws) => {
console.log(`[WS] RDP upgrade complete`);
rdpWss.emit('connection', ws, req);
});
} else { } else {
// Pass to WsAdapter's original handlers (for RDP etc) // Pass to WsAdapter's original handlers
for (const listener of existingListeners) { for (const listener of existingListeners) {
listener.call(server, req, socket, head); listener.call(server, req, socket, head);
} }

View File

@ -1,11 +1,4 @@
import { import { Injectable, Logger } from '@nestjs/common';
WebSocketGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server } from 'ws';
import * as net from 'net'; import * as net from 'net';
import { WsAuthGuard } from '../auth/ws-auth.guard'; import { WsAuthGuard } from '../auth/ws-auth.guard';
import { GuacamoleService } from './guacamole.service'; import { GuacamoleService } from './guacamole.service';
@ -13,9 +6,8 @@ import { CredentialsService } from '../vault/credentials.service';
import { HostsService } from '../connections/hosts.service'; import { HostsService } from '../connections/hosts.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@WebSocketGateway({ path: '/ws/rdp' }) @Injectable()
export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect { export class RdpGateway {
@WebSocketServer() server: Server;
private readonly logger = new Logger(RdpGateway.name); private readonly logger = new Logger(RdpGateway.name);
// Maps browser WebSocket client → live guacd TCP socket // Maps browser WebSocket client → live guacd TCP socket
@ -29,8 +21,8 @@ export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect {
private wsAuth: WsAuthGuard, private wsAuth: WsAuthGuard,
) {} ) {}
handleConnection(client: any) { handleConnection(client: any, req: any) {
const user = this.wsAuth.validateClient(client); const user = this.wsAuth.validateClient(client, req);
if (!user) { if (!user) {
client.close(4001, 'Unauthorized'); client.close(4001, 'Unauthorized');
return; return;
@ -41,6 +33,7 @@ export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect {
client.on('message', async (raw: Buffer) => { client.on('message', async (raw: Buffer) => {
try { try {
const msg = JSON.parse(raw.toString()); const msg = JSON.parse(raw.toString());
this.logger.log(`[RDP] Message: ${msg.type}`);
if (msg.type === 'connect') { if (msg.type === 'connect') {
await this.handleConnect(client, msg); await this.handleConnect(client, msg);
@ -56,15 +49,16 @@ export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect {
this.send(client, { type: 'error', message: err.message }); this.send(client, { type: 'error', message: err.message });
} }
}); });
}
handleDisconnect(client: any) { client.on('close', () => {
const socket = this.clientSockets.get(client); this.logger.log('RDP WS disconnected');
if (socket) { const socket = this.clientSockets.get(client);
socket.destroy(); if (socket) {
this.clientSockets.delete(client); socket.destroy();
this.logger.log('RDP WS disconnected — guacd socket destroyed'); this.clientSockets.delete(client);
} this.logger.log('guacd socket destroyed on WS close');
}
});
} }
private async handleConnect(client: any, msg: any) { private async handleConnect(client: any, msg: any) {
@ -90,7 +84,7 @@ export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect {
dpi: msg.dpi || 96, dpi: msg.dpi || 96,
security: msg.security || 'any', security: msg.security || 'any',
colorDepth: msg.colorDepth || 24, colorDepth: msg.colorDepth || 24,
ignoreCert: true, // default permissive — expose as setting in Task 15+ ignoreCert: true,
}); });
this.clientSockets.set(client, socket); this.clientSockets.set(client, socket);
@ -115,7 +109,7 @@ export class RdpGateway implements OnGatewayConnection, OnGatewayDisconnect {
this.send(client, { type: 'error', message: err.message }); this.send(client, { type: 'error', message: err.message });
}); });
// Connection tracking — same pattern as SSH gateway // Connection tracking
this.hosts.touchLastConnected(host.id).catch(() => {}); this.hosts.touchLastConnected(host.id).catch(() => {});
this.prisma.connectionLog this.prisma.connectionLog
.create({ data: { hostId: host.id, protocol: 'rdp' } }) .create({ data: { hostId: host.id, protocol: 'rdp' } })

View File

@ -5,6 +5,7 @@ import { useRdp } from '~/composables/useRdp'
const props = defineProps<{ const props = defineProps<{
hostId: number hostId: number
hostName: string hostName: string
sessionId: string
color?: string | null color?: string | null
}>() }>()
@ -27,6 +28,7 @@ onMounted(() => {
props.hostId, props.hostId,
props.hostName, props.hostName,
props.color ?? null, props.color ?? null,
props.sessionId,
{ {
width: container.value.clientWidth, width: container.value.clientWidth,
height: container.value.clientHeight, height: container.value.clientHeight,

View File

@ -70,6 +70,7 @@ function handleRdpClipboard(sessionId: string, text: string) {
:ref="(el) => setRdpRef(session.id, el)" :ref="(el) => setRdpRef(session.id, el)"
:host-id="session.hostId" :host-id="session.hostId"
:host-name="session.hostName" :host-name="session.hostName"
:session-id="session.id"
:color="session.color" :color="session.color"
/> />
<RdpToolbar <RdpToolbar

View File

@ -42,9 +42,11 @@ class JsonWsTunnel extends Guacamole.Tunnel {
} }
connect(_data?: string) { connect(_data?: string) {
console.log('[RDP] Tunnel opening WebSocket:', this.wsUrl)
this.ws = new WebSocket(this.wsUrl) this.ws = new WebSocket(this.wsUrl)
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('[RDP] WebSocket open, sending connect handshake')
// Step 1: send our JSON connect handshake // Step 1: send our JSON connect handshake
this.ws!.send(JSON.stringify(this.connectMsg)) this.ws!.send(JSON.stringify(this.connectMsg))
} }
@ -159,14 +161,17 @@ export function useRdp() {
hostId: number, hostId: number,
hostName: string, hostName: string,
color: string | null, color: string | null,
pendingSessionId: string,
options?: { width?: number; height?: number }, options?: { width?: number; height?: number },
) { ) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws' const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const wsUrl = `${proto}://${location.host}/ws/rdp?token=${auth.token}` const wsUrl = `${proto}://${location.host}/api/ws/rdp?token=${auth.token}`
const width = options?.width || container.clientWidth || 1920 const width = options?.width || container.clientWidth || 1920
const height = options?.height || container.clientHeight || 1080 const height = options?.height || container.clientHeight || 1080
console.log(`[RDP] Connecting to ${wsUrl} for hostId=${hostId} (${width}x${height})`)
const tunnel = new JsonWsTunnel(wsUrl, { const tunnel = new JsonWsTunnel(wsUrl, {
type: 'connect', type: 'connect',
hostId, hostId,
@ -176,12 +181,14 @@ export function useRdp() {
const client = new Guacamole.Client(tunnel) const client = new Guacamole.Client(tunnel)
// Session store ID — created optimistically, will be confirmed on 'connected' // Session ID is passed in by the caller (the pending session created in connectHost)
const sessionId = `rdp-${hostId}-${Date.now()}` const sessionId = pendingSessionId
// Wire tunnel callbacks // Wire tunnel callbacks
tunnel.onConnected = (_resolvedHostId: number, resolvedHostName: string) => { tunnel.onConnected = (_resolvedHostId: number, resolvedHostName: string) => {
sessions.addSession({ console.log(`[RDP] Connected to ${resolvedHostName}`)
// Update the pending session with the resolved host name
sessions.replaceSession(sessionId, {
key: sessionId, key: sessionId,
id: sessionId, id: sessionId,
hostId, hostId,
@ -192,7 +199,8 @@ export function useRdp() {
}) })
} }
tunnel.onDisconnected = (_reason: string) => { tunnel.onDisconnected = (reason: string) => {
console.log(`[RDP] Disconnected: ${reason}`)
sessions.removeSession(sessionId) sessions.removeSession(sessionId)
client.disconnect() client.disconnect()
} }