CI / Build & Test (push) Successful in 3m12s
- Move connect-proxy and https-proxy into proxy/ - Move certificate-authority, backup-manager, ssh-manager, rdp-manager into managers/ - Move messenger.js into host/ - Move test-dirname.cjs into test/ - Update imports, CI lint paths, and ARCHITECTURE.md
478 lines
16 KiB
JavaScript
478 lines
16 KiB
JavaScript
/**
|
||
* 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 <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 };
|