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 { TerminalGateway } from './terminal/terminal.gateway';
import { SftpGateway } from './terminal/sftp.gateway';
import { RdpGateway } from './rdp/rdp.gateway';
// Crash handlers — catch whatever is killing the process
process.on('uncaughtException', (err) => {
@ -30,9 +31,11 @@ async function bootstrap() {
const server = app.getHttpServer();
const terminalGateway = app.get(TerminalGateway);
const sftpGateway = app.get(SftpGateway);
const rdpGateway = app.get(RdpGateway);
const terminalWss = new WebSocketServer({ noServer: true });
const sftpWss = new WebSocketServer({ noServer: true });
const rdpWss = new WebSocketServer({ noServer: true });
terminalWss.on('connection', (ws, req) => {
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
const existingListeners = server.listeners('upgrade');
server.removeAllListeners('upgrade');
@ -72,8 +84,13 @@ async function bootstrap() {
console.log(`[WS] SFTP upgrade complete`);
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 {
// Pass to WsAdapter's original handlers (for RDP etc)
// Pass to WsAdapter's original handlers
for (const listener of existingListeners) {
listener.call(server, req, socket, head);
}

View File

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

View File

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

View File

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

View File

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