Files
holesail-browser/native-host/rdp-manager.js
T
Raven Scott f1e98a7edd
CI / Build & Test (push) Successful in 2m54s
docs: add CONTRIBUTING.md, CHANGELOG.md, and JSDoc to entire codebase
Add docs/CONTRIBUTING.md covering the build system, dev workflow, all
npm scripts, how to add new native host message types, code style, and
debugging guidance.

Add CHANGELOG.md at the project root documenting all features and fixes
across the 1.0.0 release.

Add JSDoc (@param, @returns) to all previously undocumented exported
functions across 35 JS files:
- native-host/holesail-manager/ (index, virtual-hosts, service-tunnels,
  servers, port-allocator)
- native-host top-level managers (startup, connect-proxy, https-proxy,
  certificate-authority, ssh-manager, rdp-manager)
- extension/background/ (logs, native-messaging, proxy, message-router)
- extension/dashboard/core/ (utils, navigation, init)
- extension/dashboard/ui/ (modal, toast, state-tag)
- extension/dashboard/pages/ (all 10 page files)
- extension/dashboard/refresh.js, events.js
- extension/dashboard/data/hostname-validator.js
- scripts/ (build-host, run-install)
2026-03-01 00:40:53 -05:00

478 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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: 2200022999
* WS ports: 2300023999
*/
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 <canvas> 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: <base64> }
* { 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<void>}
*/
async function cleanup() {
for (const [sessionId] of sessions) {
await stopSession({ sessionId }).catch(() => {});
}
}
module.exports = { startSession, stopSession, getSessions, cleanup };