/** * Remote Desktop session manager for Holesail Browser. * * Supports two protocols: * * VNC — raw byte pipe between the browser WebSocket and the Holesail TCP * tunnel. noVNC's RFB object in the browser handles all protocol * framing, canvas rendering, and mouse/keyboard input. * * RDP — node-rdpjs acts as an RDP client connecting to the Holesail tunnel * local port. Bitmap updates and input events are exchanged with the * browser as JSON messages over a bare-ws WebSocket server. * * Port pools (separate from ssh-manager to avoid collisions): * Tunnel ports: 22000–22999 * WS ports: 23000–23999 */ let Holesail = null; try { Holesail = require('holesail'); } catch (e) { if (process.stderr) process.stderr.write('[rdp-manager] holesail not available: ' + e.message + '\n'); } let WsServer = null; try { WsServer = require('bare-ws').Server; } catch (e) { if (process.stderr) process.stderr.write('[rdp-manager] bare-ws not available: ' + e.message + '\n'); } let rdpLib = null; try { rdpLib = require('node-rdpjs-2'); } catch (e) { if (process.stderr) process.stderr.write('[rdp-manager] node-rdpjs-2 not available (RDP disabled, VNC still works)\n'); } // bare-tcp is used to create a raw TCP socket for the VNC pipe let bareTcp = null; try { bareTcp = require('bare-tcp'); } catch (e) { if (process.stderr) process.stderr.write('[rdp-manager] bare-tcp not available: ' + e.message + '\n'); } function log(...args) { const msg = '[rdp-manager] ' + args.join(' '); if (process.stderr) process.stderr.write(msg + '\n'); } // ── Port allocators ────────────────────────────────────────────────────────── let nextTunnelPort = 22000; let nextWsPort = 23000; const tunnelPortFreeList = []; const wsPortFreeList = []; function allocateTunnelPort() { return tunnelPortFreeList.length > 0 ? tunnelPortFreeList.pop() : nextTunnelPort++; } function releaseTunnelPort(p) { if (typeof p === 'number') tunnelPortFreeList.push(p); } function allocateWsPort() { return wsPortFreeList.length > 0 ? wsPortFreeList.pop() : nextWsPort++; } function releaseWsPort(p) { if (typeof p === 'number') wsPortFreeList.push(p); } // ── Active sessions ────────────────────────────────────────────────────────── const sessions = new Map(); let nextSessionId = 1; // ── VNC session ────────────────────────────────────────────────────────────── /** * VNC: raw byte pipe between the browser WebSocket and the Holesail TCP socket. * noVNC handles all RFB protocol framing in the browser. * * The Holesail tunnel exposes the remote VNC server on 127.0.0.1:tunnelPort. * We connect a bare-tcp socket to that port and bridge it to the WS server. */ async function startVncSession(sessionId, holesailInst, tunnelPort, wsPort, label) { if (!bareTcp) { return { ok: false, error: 'bare-tcp not available — cannot create VNC TCP socket' }; } // Buffer data arriving from VNC server before the browser WS connects const outputBuffer = []; const MAX_BUFFER = 512 * 1024; const MAX_ENTRIES = 1000; let bufferedBytes = 0; let activeWsConn = null; let tcpSocket = null; let tcpConnected = false; function bufferOrSend(chunk) { const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); if (activeWsConn) { try { activeWsConn.write(buf); } catch (_) {} } else { outputBuffer.push(buf); bufferedBytes += buf.length; // Evict oldest entries when either the byte cap or the entry count cap is exceeded. while ((bufferedBytes > MAX_BUFFER || outputBuffer.length > MAX_ENTRIES) && outputBuffer.length > 0) { bufferedBytes -= outputBuffer.shift().length; } } } // Connect TCP socket to the Holesail tunnel local port try { tcpSocket = bareTcp.connect(tunnelPort, '127.0.0.1'); await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('TCP connect timeout')), 10000); tcpSocket.on('connect', () => { clearTimeout(timeout); tcpConnected = true; resolve(); }); tcpSocket.on('error', (err) => { clearTimeout(timeout); reject(err); }); }); } catch (e) { return { ok: false, error: 'TCP connect to VNC server failed: ' + e.message }; } tcpSocket.on('data', (chunk) => bufferOrSend(chunk)); tcpSocket.on('close', () => { log('VNC TCP socket closed id=' + sessionId); if (activeWsConn) { try { activeWsConn.end(); } catch (_) {} } const sess = sessions.get(sessionId); if (sess) sess.state = 'closed'; }); tcpSocket.on('error', (err) => { log('VNC TCP socket error id=' + sessionId + ': ' + err.message); const sess = sessions.get(sessionId); if (sess) sess.state = 'error'; }); // Start WS server — browser connects here with noVNC RFB object let wsServer = null; try { wsServer = new WsServer({ port: wsPort, host: '127.0.0.1' }, (wsConn) => { if (activeWsConn && activeWsConn !== wsConn) { try { activeWsConn.end(); } catch (_) {} } activeWsConn = wsConn; log('VNC WS client connected id=' + sessionId); // Flush buffered VNC server data const buffered = outputBuffer.splice(0); bufferedBytes = 0; for (const chunk of buffered) { try { wsConn.write(chunk); } catch (_) {} } // WS → TCP: browser (noVNC) → VNC server wsConn.on('data', (data) => { if (tcpSocket && tcpConnected) { try { tcpSocket.write(data); } catch (_) {} } }); wsConn.on('close', () => { activeWsConn = null; log('VNC WS client disconnected id=' + sessionId); }); wsConn.on('error', () => { activeWsConn = null; }); }); wsServer.on('error', (err) => { log('VNC WS server error: ' + err.message); }); } catch (e) { if (tcpSocket) try { tcpSocket.destroy(); } catch (_) {} return { ok: false, error: 'Failed to start VNC WebSocket server: ' + e.message }; } sessions.set(sessionId, { type: 'vnc', holesail: holesailInst, tcpSocket, wsServer, tunnelPort, wsPort, label, state: 'connected', createdAt: Date.now() }); return { ok: true, sessionId, wsPort }; } // ── RDP session ────────────────────────────────────────────────────────────── /** * RDP: node-rdpjs acts as an RDP client. Bitmap updates are sent to the browser * as JSON messages. The browser renders them onto a and sends back * mouse/keyboard input as JSON. * * WS message protocol (server → browser): * { type: 'connected', width, height } * { type: 'bitmap', destLeft, destTop, destRight, destBottom, * width, height, bitsPerPixel, isCompress, data: } * { type: 'close' } * { type: 'error', message } * * WS message protocol (browser → server): * { type: 'mouseMove', x, y } * { type: 'mouseButton', x, y, button, isDown } * { type: 'keyEvent', code, isDown } * { type: 'keyUnicode', code, isDown } */ async function startRdpSession(sessionId, holesailInst, tunnelPort, wsPort, payload) { if (!rdpLib) { return { ok: false, error: 'node-rdpjs-2 not available — cannot start RDP session' }; } const { username = '', password = '', domain = '', width = 1280, height = 720, label = '' } = payload; let activeWsConn = null; let rdpClient = null; function sendToWs(obj) { if (!activeWsConn) return; try { activeWsConn.write(Buffer.from(JSON.stringify(obj))); } catch (_) {} } // Create RDP client try { rdpClient = rdpLib.createClient({ domain, userName: username, password, enablePerf: true, autoLogin: true, decompress: false, screen: { width, height }, locale: 'en', logLevel: 'ERROR' }); } catch (e) { return { ok: false, error: 'Failed to create RDP client: ' + e.message }; } rdpClient.on('connect', () => { log('RDP connected id=' + sessionId); const sess = sessions.get(sessionId); if (sess) sess.state = 'connected'; sendToWs({ type: 'connected', width, height }); }); rdpClient.on('close', () => { log('RDP closed id=' + sessionId); const sess = sessions.get(sessionId); if (sess) sess.state = 'closed'; sendToWs({ type: 'close' }); }); rdpClient.on('error', (err) => { const msg = (err && err.message) || String(err); log('RDP error id=' + sessionId + ': ' + msg); const sess = sessions.get(sessionId); if (sess) sess.state = 'error'; sendToWs({ type: 'error', message: msg }); }); rdpClient.on('bitmap', (bitmap) => { if (!activeWsConn) return; sendToWs({ type: 'bitmap', destLeft: bitmap.destLeft, destTop: bitmap.destTop, destRight: bitmap.destRight, destBottom: bitmap.destBottom, width: bitmap.width, height: bitmap.height, bitsPerPixel: bitmap.bitsPerPixel, isCompress: bitmap.isCompress, data: bitmap.data ? bitmap.data.toString('base64') : '' }); }); // Start WS server let wsServer = null; try { wsServer = new WsServer({ port: wsPort, host: '127.0.0.1' }, (wsConn) => { if (activeWsConn && activeWsConn !== wsConn) { try { activeWsConn.end(); } catch (_) {} } activeWsConn = wsConn; log('RDP WS client connected id=' + sessionId); wsConn.on('data', (data) => { let msg; try { msg = JSON.parse(data.toString()); } catch (_) { return; } if (!rdpClient) return; try { if (msg.type === 'mouseMove') { rdpClient.sendPointerEvent(msg.x, msg.y, 0, false); } else if (msg.type === 'mouseButton') { rdpClient.sendPointerEvent(msg.x, msg.y, msg.button || 1, !!msg.isDown); } else if (msg.type === 'keyEvent') { rdpClient.sendKeyEventScancode(msg.code, !!msg.isDown); } else if (msg.type === 'keyUnicode') { rdpClient.sendKeyEventUnicode(msg.code, !!msg.isDown); } } catch (_) {} }); wsConn.on('close', () => { activeWsConn = null; log('RDP WS client disconnected id=' + sessionId); }); wsConn.on('error', () => { activeWsConn = null; }); }); wsServer.on('error', (err) => { log('RDP WS server error: ' + err.message); }); } catch (e) { if (rdpClient) try { rdpClient.close(); } catch (_) {} return { ok: false, error: 'Failed to start RDP WebSocket server: ' + e.message }; } // Connect RDP client to the Holesail tunnel local port try { rdpClient.connect('127.0.0.1', tunnelPort); } catch (e) { if (wsServer) try { wsServer.close(); } catch (_) {} if (rdpClient) try { rdpClient.close(); } catch (_) {} return { ok: false, error: 'RDP connect failed: ' + e.message }; } sessions.set(sessionId, { type: 'rdp', holesail: holesailInst, rdpClient, wsServer, tunnelPort, wsPort, label, username, width, height, state: 'connecting', createdAt: Date.now() }); return { ok: true, sessionId, wsPort }; } // ── Public API ─────────────────────────────────────────────────────────────── /** * Start a VNC or RDP session over a Holesail tunnel. * Allocates a tunnel port and a WebSocket port, connects to the remote peer, * then delegates to `startVncSession` or `startRdpSession`. * @param {object} payload * @param {'vnc'|'rdp'} payload.type - Protocol to use. * @param {string} payload.hsUrl - hs:// key of the remote desktop server. * @param {number} [payload.port] - Remote desktop port (default: 5900 for VNC, 3389 for RDP). * @param {string} [payload.label=''] - Human-readable label for the UI. * @returns {Promise<{ok: boolean, sessionId?: string, wsPort?: number, error?: string}>} */ async function startSession(payload) { const { type, hsUrl, port, label = '' } = payload; if (!Holesail) return { ok: false, error: 'holesail not available' }; if (!WsServer) return { ok: false, error: 'bare-ws not available' }; if (!hsUrl) return { ok: false, error: 'hsUrl required' }; if (!type || (type !== 'vnc' && type !== 'rdp')) { return { ok: false, error: 'type must be "vnc" or "rdp"' }; } const sessionId = 'rdp-' + (nextSessionId++); const tunnelPort = allocateTunnelPort(); const wsPort = allocateWsPort(); log('startSession id=' + sessionId + ' type=' + type + ' tunnelPort=' + tunnelPort + ' wsPort=' + wsPort); // Start Holesail client tunnel let holesailInst = null; try { holesailInst = new Holesail({ client: true, key: hsUrl, host: '127.0.0.1', port: tunnelPort }); const TUNNEL_READY_TIMEOUT_MS = 30000; await Promise.race([ holesailInst.ready(), new Promise((_, reject) => setTimeout(() => reject(new Error('Tunnel ready timeout after ' + TUNNEL_READY_TIMEOUT_MS + 'ms')), TUNNEL_READY_TIMEOUT_MS)) ]); log('tunnel ready on 127.0.0.1:' + tunnelPort); } catch (e) { if (holesailInst) try { holesailInst.close(); } catch (_) {} releaseTunnelPort(tunnelPort); releaseWsPort(wsPort); return { ok: false, error: 'Tunnel failed: ' + e.message }; } let result; if (type === 'vnc') { result = await startVncSession(sessionId, holesailInst, tunnelPort, wsPort, label); } else { result = await startRdpSession(sessionId, holesailInst, tunnelPort, wsPort, payload); } if (!result.ok) { if (holesailInst) try { await holesailInst.close(); } catch (_) {} releaseTunnelPort(tunnelPort); releaseWsPort(wsPort); } return result; } /** * Stop a VNC or RDP session: close the protocol client, WebSocket server, * Holesail tunnel, and release all allocated ports. * @param {object} payload * @param {string} payload.sessionId * @returns {Promise<{ok: boolean, error?: string}>} */ async function stopSession(payload) { const { sessionId } = payload; const sess = sessions.get(sessionId); if (!sess) return { ok: false, error: 'Session not found' }; log('stopSession id=' + sessionId + ' type=' + sess.type); sess.state = 'stopping'; if (sess.type === 'vnc') { if (sess.tcpSocket) try { sess.tcpSocket.destroy(); } catch (_) {} } else if (sess.type === 'rdp') { if (sess.rdpClient) try { sess.rdpClient.close(); } catch (_) {} } if (sess.wsServer) try { sess.wsServer.close(); } catch (_) {} if (sess.holesail) try { await sess.holesail.close(); } catch (_) {} releaseTunnelPort(sess.tunnelPort); releaseWsPort(sess.wsPort); sessions.delete(sessionId); return { ok: true }; } /** * Return a snapshot of all active RDP/VNC sessions. * @returns {Array<{sessionId: string, type: string, label: string, wsPort: number, state: string, width: number, height: number, createdAt: number}>} */ function getSessions() { const list = []; for (const [sessionId, s] of sessions) { list.push({ sessionId, type: s.type, label: s.label, wsPort: s.wsPort, state: s.state, width: s.width, height: s.height, createdAt: s.createdAt }); } return list; } /** * Stop all active RDP/VNC sessions. Called during native host shutdown. * @returns {Promise} */ async function cleanup() { for (const [sessionId] of sessions) { await stopSession({ sessionId }).catch(() => {}); } } module.exports = { startSession, stopSession, getSessions, cleanup };