From 72526487c38a6be815ebe0bac9ebd999612c9e9c Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 14 Mar 2026 04:51:03 -0400 Subject: [PATCH] fix(rdp): replace class extends with direct instance method override on Guacamole.Tunnel --- frontend/composables/useRdp.ts | 209 ++++++++++++++------------------- 1 file changed, 90 insertions(+), 119 deletions(-) diff --git a/frontend/composables/useRdp.ts b/frontend/composables/useRdp.ts index 58a687c..91a7aad 100644 --- a/frontend/composables/useRdp.ts +++ b/frontend/composables/useRdp.ts @@ -3,123 +3,29 @@ import { useAuthStore } from '~/stores/auth.store' import { useSessionStore } from '~/stores/session.store' /** - * Custom Guacamole Tunnel that speaks JSON over WebSocket. - * - * Why custom instead of Guacamole.WebSocketTunnel: - * - The standard WebSocketTunnel expects raw Guacamole protocol over WS. - * - Our /ws/rdp gateway wraps all messages in JSON envelopes (same pattern - * as /ws/terminal and /ws/sftp) for auth, connect handshake, and error - * signaling. - * - This tunnel bridges the two: guacamole-common-js sees raw Guacamole - * instructions while our gateway sees consistent JSON messages. - * - * IMPORTANT: Guacamole.Tunnel assigns connect/sendMessage/disconnect as - * instance properties in its constructor (not prototype methods). We MUST - * reassign them in our constructor AFTER super() to override the no-ops. + * Creates a Guacamole-compatible tunnel that speaks JSON over WebSocket. + * Does NOT extend Guacamole.Tunnel (its constructor assigns no-op instance + * properties that shadow subclass methods). Instead, creates a base tunnel + * and overwrites the methods directly. */ -class JsonWsTunnel extends Guacamole.Tunnel { - private ws: WebSocket | null = null - private readonly wsUrl: string - private readonly connectMsg: object +function createJsonWsTunnel(wsUrl: string, connectMsg: object) { + const tunnel = new Guacamole.Tunnel() + let ws: WebSocket | null = null - // Expose for external callers (connected event, disconnect action) - onConnected: ((hostId: number, hostName: string) => void) | null = null - onDisconnected: ((reason: string) => void) | null = null - onGatewayError: ((message: string) => void) | null = null - - constructor(wsUrl: string, connectMsg: object) { - super() - this.wsUrl = wsUrl - this.connectMsg = connectMsg - - // Override instance methods set by Guacamole.Tunnel constructor - this.connect = this._connect.bind(this) - this.disconnect = this._disconnect.bind(this) - this.sendMessage = this._sendMessage.bind(this) - } - - private _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') - this.ws!.send(JSON.stringify(this.connectMsg)) - } - - this.ws.onmessage = (event: MessageEvent) => { - const msg = JSON.parse(event.data as string) - - switch (msg.type) { - case 'connected': - this.onConnected?.(msg.hostId, msg.hostName) - break - - case 'guac': { - const instruction: string = msg.instruction - this._dispatchInstructions(instruction) - break - } - - case 'error': - this.onGatewayError?.(msg.message as string) - this.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, msg.message)) - break - - case 'disconnected': - this.onDisconnected?.(msg.reason as string) - break - } - } - - this.ws.onclose = (event: CloseEvent) => { - if (event.code === 4001) { - this.onerror?.(new Guacamole.Status(Guacamole.Status.Code.CLIENT_FORBIDDEN, 'Unauthorized')) - } - this.onDisconnected?.('WebSocket closed') - } - - this.ws.onerror = () => { - this.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, 'WebSocket error')) - } - } - - private _disconnect() { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.close() - } - this.ws = null - } - - private _sendMessage(...elements: any[]) { - // Guacamole.Client calls sendMessage(opcode, arg1, arg2, ...) with individual elements. - // Encode into Guacamole wire format: ".,.,...;" - if (this.ws?.readyState === WebSocket.OPEN) { - const instruction = elements.map((e: any) => { - const s = String(e) - return `${s.length}.${s}` - }).join(',') + ';' - this.ws.send(JSON.stringify({ type: 'guac', instruction })) - } - } - - /** - * Parse one or more Guacamole instructions from a raw buffer string - * and call oninstruction for each. - */ - private _dispatchInstructions(raw: string) { - if (!this.oninstruction) return + // Custom callbacks for our JSON protocol layer + let onConnected: ((hostId: number, hostName: string) => void) | null = null + let onDisconnected: ((reason: string) => void) | null = null + let onGatewayError: ((message: string) => void) | null = null + function dispatchInstructions(raw: string) { + if (!tunnel.oninstruction) return let remaining = raw while (remaining.length > 0) { const semicolonIdx = remaining.indexOf(';') if (semicolonIdx === -1) break - const instruction = remaining.substring(0, semicolonIdx) remaining = remaining.substring(semicolonIdx + 1) - if (!instruction) continue - const parts: string[] = [] let pos = 0 while (pos < instruction.length) { @@ -127,18 +33,84 @@ class JsonWsTunnel extends Guacamole.Tunnel { if (dotIdx === -1) break const len = parseInt(instruction.substring(pos, dotIdx), 10) if (isNaN(len)) break - const value = instruction.substring(dotIdx + 1, dotIdx + 1 + len) - parts.push(value) + parts.push(instruction.substring(dotIdx + 1, dotIdx + 1 + len)) pos = dotIdx + 1 + len + 1 } - if (parts.length > 0) { - const opcode = parts[0] - const args = parts.slice(1) - this.oninstruction(opcode, args) + tunnel.oninstruction(parts[0], parts.slice(1)) } } } + + // Override the no-op instance methods + tunnel.connect = (_data?: string) => { + console.log('[RDP] Tunnel opening WebSocket:', wsUrl) + ws = new WebSocket(wsUrl) + + ws.onopen = () => { + console.log('[RDP] WebSocket open, sending connect handshake') + ws!.send(JSON.stringify(connectMsg)) + } + + ws.onmessage = (event: MessageEvent) => { + const msg = JSON.parse(event.data as string) + switch (msg.type) { + case 'connected': + onConnected?.(msg.hostId, msg.hostName) + break + case 'guac': + dispatchInstructions(msg.instruction) + break + case 'error': + onGatewayError?.(msg.message) + tunnel.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, msg.message)) + break + case 'disconnected': + onDisconnected?.(msg.reason) + break + } + } + + ws.onclose = (event: CloseEvent) => { + console.log('[RDP] WebSocket closed, code:', event.code) + if (event.code === 4001) { + tunnel.onerror?.(new Guacamole.Status(Guacamole.Status.Code.CLIENT_FORBIDDEN, 'Unauthorized')) + } + onDisconnected?.('WebSocket closed') + } + + ws.onerror = () => { + console.error('[RDP] WebSocket error') + tunnel.onerror?.(new Guacamole.Status(Guacamole.Status.Code.SERVER_ERROR, 'WebSocket error')) + } + } + + tunnel.disconnect = () => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close() + } + ws = null + } + + tunnel.sendMessage = (...elements: any[]) => { + if (ws?.readyState === WebSocket.OPEN) { + const instruction = elements.map((e: any) => { + const s = String(e) + return `${s.length}.${s}` + }).join(',') + ';' + ws.send(JSON.stringify({ type: 'guac', instruction })) + } + } + + return { + tunnel, + get onConnected() { return onConnected }, + set onConnected(fn) { onConnected = fn }, + get onDisconnected() { return onDisconnected }, + set onDisconnected(fn) { onDisconnected = fn }, + get onGatewayError() { return onGatewayError }, + set onGatewayError(fn) { onGatewayError = fn }, + } } // ─── Composable ──────────────────────────────────────────────────────────────── @@ -163,18 +135,17 @@ export function useRdp() { console.log(`[RDP] Connecting to ${wsUrl} for hostId=${hostId} (${width}x${height})`) - const tunnel = new JsonWsTunnel(wsUrl, { + const wrapper = createJsonWsTunnel(wsUrl, { type: 'connect', hostId, width, height, }) - const client = new Guacamole.Client(tunnel) - + const client = new Guacamole.Client(wrapper.tunnel) const sessionId = pendingSessionId - tunnel.onConnected = (_resolvedHostId: number, resolvedHostName: string) => { + wrapper.onConnected = (_resolvedHostId: number, resolvedHostName: string) => { console.log(`[RDP] Connected to ${resolvedHostName}`) sessions.replaceSession(sessionId, { key: sessionId, @@ -187,13 +158,13 @@ export function useRdp() { }) } - tunnel.onDisconnected = (reason: string) => { + wrapper.onDisconnected = (reason: string) => { console.log(`[RDP] Disconnected: ${reason}`) sessions.removeSession(sessionId) client.disconnect() } - tunnel.onGatewayError = (message: string) => { + wrapper.onGatewayError = (message: string) => { console.error('[RDP] Gateway error:', message) }