feat: self-hosted remote support over VNC

Browser-based remote control (noVNC) with invite links, per-user access
control, garagedoor SSO and a persisted client list.

The hub proxies RFB rather than pointing the browser at a VNC server. That
is what lets it authenticate upstream with a stored password the browser
never sees, and enforce view-only by dropping input messages on the
client->server stream instead of hiding buttons.

Machines are reachable two ways: direct TCP for LAN hosts, or an outbound
agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the
agent dependency-free, and node:sqlite keeps the image free of native
builds.

Ships with an end-to-end suite that boots the real server against a fake
VNC server and a fake auth service (72 assertions), plus Gitea Actions
CI/CD to Portainer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:37:35 -07:00
co-authored by Claude Opus 5
commit 999717f77b
34 changed files with 7057 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
'use strict';
// Splices a browser WebSocket to a client machine's VNC server, keeping a
// registry of what is live so operators can see and kill active sessions.
const net = require('net');
const { Transform, pipeline } = require('stream');
const { createWebSocketStream } = require('ws');
const { decrypt } = require('../crypto');
const { sessions, clients, audit } = require('../db');
const { handshakeWithServer, handshakeWithBrowser, ViewOnlyFilter, ByteReader } = require('./rfb');
const { canControl } = require('../auth');
const hub = require('./hub');
/** sessionId -> live session handle */
const live = new Map();
class Counter extends Transform {
constructor() {
super();
this.bytes = 0;
}
_transform(chunk, _enc, cb) {
this.bytes += chunk.length;
cb(null, chunk);
}
}
/** Drops input-bearing RFB messages so a viewer physically cannot control. */
class ViewOnlyTransform extends Transform {
constructor() {
super();
this.filter = new ViewOnlyFilter();
}
_transform(chunk, _enc, cb) {
let out;
try {
out = this.filter.push(chunk);
} catch (err) {
return cb(err);
}
cb(null, out || undefined);
}
get blocked() {
return this.filter.blocked;
}
}
function connectDirect(client, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
if (!client.host) return reject(new Error('this client has no host configured'));
const socket = net.connect({ host: client.host, port: client.port || 5900 });
socket.setNoDelay(true);
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`timed out connecting to ${client.host}:${client.port || 5900}`));
}, timeoutMs);
socket.once('connect', () => {
clearTimeout(timer);
socket.removeListener('error', onError);
resolve(socket);
});
const onError = (err) => {
clearTimeout(timer);
reject(new Error(`cannot reach ${client.host}:${client.port || 5900} (${err.code || err.message})`));
};
socket.once('error', onError);
});
}
async function connectUpstream(client, meta) {
if (client.mode === 'agent') {
return hub.openTunnel(client.id, {
requireConsent: !!client.require_consent,
operator: meta.username,
role: meta.role,
sessionId: meta.sessionId,
});
}
return connectDirect(client);
}
/**
* Take over a browser WebSocket and run a VNC session on it.
* `ctx` = { sessionId, client, username, role, source, inviteId, remoteIp }
*/
async function startSession(browserWs, ctx) {
const { sessionId, client, username, role } = ctx;
const control = canControl(role);
let upstream;
try {
upstream = await connectUpstream(client, { username, role, sessionId });
await handshakeWithServer(upstream, decrypt(client.vnc_password_enc));
} catch (err) {
if (upstream) upstream.destroy();
audit(username, 'session.failed', client.id, { error: err.message });
// The browser has not started RFB yet, so a close reason is still readable
// by the viewer page. WebSocket close reasons are capped at 123 bytes.
try { browserWs.close(4500, String(err.message).slice(0, 120)); } catch { /* gone */ }
return;
}
sessions.start({
id: sessionId,
client_id: client.id,
client_name: client.name,
username,
source: ctx.source || 'web',
invite_id: ctx.inviteId || null,
role,
remote_ip: ctx.remoteIp || null,
});
const browserStream = createWebSocketStream(browserWs, { allowHalfOpen: false });
try {
await handshakeWithBrowser(browserStream);
// ClientInit is a single shared-desktop flag and belongs to the handshake,
// not to the message stream. Relay it by hand: the view-only filter would
// otherwise try to read it as a message type and lose the framing.
const clientInit = await new ByteReader(browserStream, 20_000).read(1);
upstream.write(clientInit);
} catch (err) {
upstream.destroy();
browserStream.destroy();
sessions.end(sessionId, { reason: `handshake: ${err.message}` });
return;
}
const toClient = new Counter(); // browser -> VNC server
const toBrowser = new Counter(); // VNC server -> browser
const guard = control ? null : new ViewOnlyTransform();
let ended = false;
const finish = (reason) => {
if (ended) return;
ended = true;
live.delete(sessionId);
sessions.end(sessionId, {
bytesIn: toClient.bytes,
bytesOut: toBrowser.bytes,
reason,
});
hub.notifySessionEnded(client.id, sessionId);
upstream.destroy();
browserStream.destroy();
};
const outbound = guard ? [browserStream, guard, toClient, upstream] : [browserStream, toClient, upstream];
pipeline(...outbound, (err) => finish(err ? `client stream: ${err.message}` : 'closed by viewer'));
pipeline(upstream, toBrowser, browserStream, (err) => finish(err ? `server stream: ${err.message}` : 'closed by host'));
live.set(sessionId, {
id: sessionId,
clientId: client.id,
clientName: client.name,
username,
role,
source: ctx.source || 'web',
remoteIp: ctx.remoteIp || null,
startedAt: Date.now(),
get bytesIn() { return toClient.bytes; },
get bytesOut() { return toBrowser.bytes; },
get blockedInputs() { return guard ? guard.blocked : 0; },
kill(reason) {
try { browserWs.close(4008, String(reason).slice(0, 120)); } catch { /* gone */ }
finish(reason);
},
});
clients.touch(client.id, ctx.remoteIp);
audit(username, 'session.start', client.id, { sessionId, role, source: ctx.source || 'web' });
}
function listLive() {
return Array.from(live.values()).map((s) => ({
id: s.id,
clientId: s.clientId,
clientName: s.clientName,
username: s.username,
role: s.role,
source: s.source,
remoteIp: s.remoteIp,
startedAt: s.startedAt,
bytesIn: s.bytesIn,
bytesOut: s.bytesOut,
blockedInputs: s.blockedInputs,
}));
}
function killSession(sessionId, reason = 'disconnected by an administrator') {
const s = live.get(sessionId);
if (!s) return false;
s.kill(reason);
return true;
}
function killSessionsForClient(clientId, reason) {
let n = 0;
for (const s of Array.from(live.values())) {
if (s.clientId === clientId) {
s.kill(reason);
n++;
}
}
return n;
}
module.exports = { startSession, listLive, killSession, killSessionsForClient };
+199
View File
@@ -0,0 +1,199 @@
'use strict';
// Minimal single-block DES-ECB encryption.
//
// Why this exists: RFB "VNC Authentication" (security type 2) is DES-based, and
// Node's OpenSSL 3 build no longer exposes des-ecb outside the legacy provider
// (`createCipheriv('des-ecb', ...)` throws "digital envelope routines::unsupported").
// So the hub carries its own DES purely to answer the auth challenge. It is not
// used for anything that needs to be secure — VNC auth is weak by design; the
// transport is protected by TLS in front of the hub instead.
const IP = [
58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
];
const FP = [
40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
];
const E = [
32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13,
12, 13, 14, 15, 16, 17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25,
24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
];
const P = [
16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
];
const PC1 = [
57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18,
10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22,
14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4,
];
const PC2 = [
14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10,
23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2,
41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48,
44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
];
const SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
const S = [
[14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7,
0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8,
4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0,
15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13],
[15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10,
3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5,
0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15,
13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9],
[10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8,
13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1,
13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7,
1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12],
[7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15,
13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9,
10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4,
3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14],
[2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9,
14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6,
4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14,
11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3],
[12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11,
10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8,
9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6,
4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13],
[4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1,
13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6,
1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2,
6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12],
[13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7,
1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2,
7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8,
2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11],
];
function bytesToBits(buf) {
const bits = new Uint8Array(buf.length * 8);
for (let i = 0; i < buf.length; i++) {
for (let b = 0; b < 8; b++) bits[i * 8 + b] = (buf[i] >> (7 - b)) & 1;
}
return bits;
}
function bitsToBytes(bits) {
const out = Buffer.alloc(bits.length / 8);
for (let i = 0; i < out.length; i++) {
let v = 0;
for (let b = 0; b < 8; b++) v = (v << 1) | bits[i * 8 + b];
out[i] = v;
}
return out;
}
function permute(bits, table) {
const out = new Uint8Array(table.length);
for (let i = 0; i < table.length; i++) out[i] = bits[table[i] - 1];
return out;
}
function rotateLeft(bits, n) {
const out = new Uint8Array(bits.length);
for (let i = 0; i < bits.length; i++) out[i] = bits[(i + n) % bits.length];
return out;
}
function subkeys(keyBits) {
const pc1 = permute(keyBits, PC1);
let c = pc1.slice(0, 28);
let d = pc1.slice(28, 56);
const keys = [];
for (let round = 0; round < 16; round++) {
c = rotateLeft(c, SHIFTS[round]);
d = rotateLeft(d, SHIFTS[round]);
const cd = new Uint8Array(56);
cd.set(c, 0);
cd.set(d, 28);
keys.push(permute(cd, PC2));
}
return keys;
}
function feistel(rBits, subkey) {
const expanded = permute(rBits, E);
const x = new Uint8Array(48);
for (let i = 0; i < 48; i++) x[i] = expanded[i] ^ subkey[i];
const sOut = new Uint8Array(32);
for (let box = 0; box < 8; box++) {
const o = box * 6;
const row = (x[o] << 1) | x[o + 5];
const col = (x[o + 1] << 3) | (x[o + 2] << 2) | (x[o + 3] << 1) | x[o + 4];
const val = S[box][row * 16 + col];
for (let b = 0; b < 4; b++) sOut[box * 4 + b] = (val >> (3 - b)) & 1;
}
return permute(sOut, P);
}
/** Encrypt one 8-byte block with an 8-byte key. */
function encryptBlock(block, key) {
const keys = subkeys(bytesToBits(key));
const ip = permute(bytesToBits(block), IP);
let l = ip.slice(0, 32);
let r = ip.slice(32, 64);
for (let round = 0; round < 16; round++) {
const f = feistel(r, keys[round]);
const next = new Uint8Array(32);
for (let i = 0; i < 32; i++) next[i] = l[i] ^ f[i];
l = r;
r = next;
}
const preOutput = new Uint8Array(64);
preOutput.set(r, 0);
preOutput.set(l, 32);
return bitsToBytes(permute(preOutput, FP));
}
/** ECB over a buffer whose length is a multiple of 8. No padding. */
function encryptEcb(data, key) {
if (data.length % 8 !== 0) throw new Error('DES-ECB input must be a multiple of 8 bytes');
const out = Buffer.alloc(data.length);
for (let off = 0; off < data.length; off += 8) {
encryptBlock(data.subarray(off, off + 8), key).copy(out, off);
}
return out;
}
function reverseBits(byte) {
let r = 0;
for (let i = 0; i < 8; i++) r |= ((byte >> i) & 1) << (7 - i);
return r;
}
/**
* Answer an RFB VNC Authentication challenge.
* The DES key is the password truncated/zero-padded to 8 bytes, with the bits of
* each byte reversed — a quirk of the original AT&T implementation.
*/
function vncAuthResponse(challenge, password) {
const key = Buffer.alloc(8, 0);
const pw = Buffer.from(String(password || ''), 'latin1');
for (let i = 0; i < 8 && i < pw.length; i++) key[i] = reverseBits(pw[i]);
return encryptEcb(challenge, key);
}
module.exports = { encryptBlock, encryptEcb, vncAuthResponse };
+215
View File
@@ -0,0 +1,215 @@
'use strict';
// Agent registry and tunnel broker.
//
// Agent-mode clients sit behind NAT, so they dial *out* to the hub and hold a
// control WebSocket open. When someone wants to view that machine, the hub asks
// the agent over that control channel to open a second, data-only WebSocket; the
// agent pipes it to the local VNC server. The hub pairs that data socket with
// the waiting browser session.
const { createWebSocketStream } = require('ws');
const config = require('../config');
const { clients, audit } = require('../db');
const { randomToken } = require('../crypto');
const HEARTBEAT_MS = 30_000;
class Hub {
constructor() {
/** clientId -> { ws, info, connectedAt, lastSeen, alive } */
this.agents = new Map();
/** tunnelId -> { clientId, resolve, reject, timer } */
this.pending = new Map();
this.heartbeat = setInterval(() => this._sweep(), HEARTBEAT_MS);
this.heartbeat.unref?.();
}
/* ------------------------------------------------------------- control */
handleAgentSocket(ws, client, remoteIp) {
// A machine may only have one live control channel; a reconnect wins.
const existing = this.agents.get(client.id);
if (existing && existing.ws !== ws) {
try { existing.ws.close(4001, 'replaced by a newer connection'); } catch { /* already gone */ }
}
const entry = { ws, info: {}, connectedAt: Date.now(), lastSeen: Date.now(), alive: true, lastIp: remoteIp };
this.agents.set(client.id, entry);
clients.touch(client.id, remoteIp);
ws.on('pong', () => {
entry.alive = true;
entry.lastSeen = Date.now();
clients.touch(client.id, remoteIp);
});
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
return;
}
entry.lastSeen = Date.now();
this._onAgentMessage(client, entry, msg, remoteIp);
});
ws.on('close', () => {
if (this.agents.get(client.id) === entry) this.agents.delete(client.id);
// Fail anything that was waiting on this agent.
for (const [tunnelId, p] of this.pending) {
if (p.clientId === client.id) this._rejectPending(tunnelId, new Error('agent disconnected'));
}
});
ws.on('error', () => { /* close handler does the cleanup */ });
this._send(ws, {
type: 'welcome',
clientId: client.id,
name: client.name,
requireConsent: !!client.require_consent,
heartbeatMs: HEARTBEAT_MS,
});
}
_onAgentMessage(client, entry, msg, remoteIp) {
switch (msg.type) {
case 'hello': {
entry.info = {
version: msg.version,
os: msg.os,
hostname: msg.hostname,
vncPort: msg.vncPort,
};
clients.update(client.id, {
os: msg.os ?? null,
hostname: msg.hostname ?? null,
agent_version: msg.version ?? null,
last_seen_at: Date.now(),
last_ip: remoteIp ?? null,
});
break;
}
case 'denied': {
this._rejectPending(msg.tunnelId, new Error(msg.reason || 'the person at that machine declined'));
break;
}
case 'error': {
this._rejectPending(msg.tunnelId, new Error(msg.message || 'agent reported an error'));
break;
}
default:
break;
}
}
_send(ws, obj) {
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
}
_sweep() {
const now = Date.now();
for (const [clientId, entry] of this.agents) {
if (!entry.alive) {
try { entry.ws.terminate(); } catch { /* already gone */ }
this.agents.delete(clientId);
continue;
}
entry.alive = false;
try { entry.ws.ping(); } catch { /* handled on next sweep */ }
if (now - entry.lastSeen < config.agentOfflineAfterMs) clients.touch(clientId, entry.lastIp);
}
}
/* -------------------------------------------------------------- status */
isOnline(clientId) {
return this.agents.has(clientId);
}
onlineIds() {
return Array.from(this.agents.keys());
}
agentInfo(clientId) {
const e = this.agents.get(clientId);
return e ? { ...e.info, connectedAt: e.connectedAt, lastSeen: e.lastSeen } : null;
}
/* ------------------------------------------------------------- tunnels */
/**
* Ask an agent to open a data tunnel. Resolves with a Duplex carrying the raw
* RFB byte stream from the client machine's local VNC server.
*/
openTunnel(clientId, meta = {}) {
const entry = this.agents.get(clientId);
if (!entry) return Promise.reject(new Error('that machine is offline'));
const tunnelId = randomToken(16);
const requireConsent = !!meta.requireConsent;
const timeoutMs = requireConsent ? config.consentTimeoutMs : 15_000;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(tunnelId);
reject(new Error(requireConsent
? 'no response to the connection request on that machine'
: 'the agent did not open a tunnel in time'));
}, timeoutMs);
timer.unref?.();
this.pending.set(tunnelId, { clientId, resolve, reject, timer });
this._send(entry.ws, {
type: 'open',
tunnelId,
requireConsent,
operator: meta.operator || 'someone',
role: meta.role || 'viewer',
sessionId: meta.sessionId,
});
});
}
/** Called when the agent's data socket arrives and claims a pending tunnel. */
handleTunnelSocket(ws, clientId, tunnelId) {
const pending = this.pending.get(tunnelId);
if (!pending || pending.clientId !== clientId) {
try { ws.close(4004, 'unknown tunnel'); } catch { /* nothing to do */ }
return false;
}
clearTimeout(pending.timer);
this.pending.delete(tunnelId);
pending.resolve(createWebSocketStream(ws, { allowHalfOpen: false }));
return true;
}
_rejectPending(tunnelId, err) {
const pending = this.pending.get(tunnelId);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(tunnelId);
pending.reject(err);
}
/** Tell an agent to drop whatever it is doing (used by force-disconnect). */
notifySessionEnded(clientId, sessionId) {
const entry = this.agents.get(clientId);
if (entry) this._send(entry.ws, { type: 'session-ended', sessionId });
}
disconnectAgent(clientId, reason = 'removed') {
const entry = this.agents.get(clientId);
if (!entry) return false;
try { entry.ws.close(4003, reason); } catch { /* already gone */ }
this.agents.delete(clientId);
audit(null, 'agent.disconnect', clientId, { reason });
return true;
}
}
module.exports = new Hub();
+274
View File
@@ -0,0 +1,274 @@
'use strict';
// RFB protocol handling for the hub.
//
// The hub is a man-in-the-middle by design: it completes the RFB handshake with
// the real VNC server itself (including VNC Authentication using a password the
// browser never receives), and separately presents a "no authentication needed"
// handshake to the browser. Once both sides are past ServerInit the two streams
// are spliced together.
//
// That MITM position is also what makes view-only enforceable: the client->server
// direction is parsed and input-bearing messages are dropped, so a viewer cannot
// send keystrokes no matter what its browser does.
const { vncAuthResponse } = require('./des');
const SEC_NONE = 1;
const SEC_VNC_AUTH = 2;
/**
* Reads exact byte counts off a Readable without putting it into flowing mode,
* so anything we do not consume stays in the stream's internal buffer and is
* picked up by the later pipe().
*/
class ByteReader {
constructor(stream, timeoutMs = 20_000) {
this.stream = stream;
this.timeoutMs = timeoutMs;
}
read(n) {
return new Promise((resolve, reject) => {
const attempt = () => {
const buf = this.stream.read(n);
if (buf) {
cleanup();
resolve(buf);
}
};
const onEnd = () => {
cleanup();
reject(new Error('connection closed during RFB handshake'));
};
const onError = (err) => {
cleanup();
reject(err);
};
const timer = setTimeout(() => {
cleanup();
reject(new Error('timed out during RFB handshake'));
}, this.timeoutMs);
const cleanup = () => {
clearTimeout(timer);
this.stream.off('readable', attempt);
this.stream.off('end', onEnd);
this.stream.off('close', onEnd);
this.stream.off('error', onError);
};
this.stream.on('readable', attempt);
this.stream.on('end', onEnd);
this.stream.on('close', onEnd);
this.stream.on('error', onError);
attempt();
});
}
async readU8() {
return (await this.read(1))[0];
}
async readU32() {
return (await this.read(4)).readUInt32BE(0);
}
// RFB failure reasons are a u32 length followed by that many bytes of text.
async readReason() {
const len = await this.readU32();
if (!len) return '';
return (await this.read(Math.min(len, 4096))).toString('utf8');
}
}
function parseVersion(buf) {
const text = buf.toString('ascii');
const m = /^RFB (\d{3})\.(\d{3})\n$/.exec(text);
if (!m) throw new Error(`not a VNC server (got ${JSON.stringify(text)})`);
return { major: Number(m[1]), minor: Number(m[2]) };
}
/**
* Act as a VNC *client* toward the real server: version negotiation, security
* negotiation, VNC Authentication if required. Returns once the server is ready
* for ClientInit, which the browser will supply.
*/
async function handshakeWithServer(stream, password, timeoutMs = 20_000) {
const r = new ByteReader(stream, timeoutMs);
const { major, minor: rawMinor } = parseVersion(await r.read(12));
// Apple advertises 003.889; anything above 3.8 is negotiated down to 3.8.
const minor = rawMinor > 8 ? 8 : rawMinor;
const negotiated = minor >= 8 ? 8 : minor >= 7 ? 7 : 3;
stream.write(Buffer.from(`RFB 003.00${negotiated}\n`, 'ascii'));
let secType;
if (negotiated >= 7) {
const count = await r.readU8();
if (count === 0) throw new Error(`server refused connection: ${await r.readReason()}`);
const types = Array.from(await r.read(count));
if (password && types.includes(SEC_VNC_AUTH)) secType = SEC_VNC_AUTH;
else if (types.includes(SEC_NONE)) secType = SEC_NONE;
else if (types.includes(SEC_VNC_AUTH)) {
throw new Error('VNC server requires a password but none is stored for this client');
} else {
throw new Error(`no supported VNC security type (server offered ${types.join(', ')})`);
}
stream.write(Buffer.from([secType]));
} else {
secType = await r.readU32();
if (secType === 0) throw new Error(`server refused connection: ${await r.readReason()}`);
if (secType === SEC_VNC_AUTH && !password) {
throw new Error('VNC server requires a password but none is stored for this client');
}
}
if (secType === SEC_VNC_AUTH) {
const challenge = await r.read(16);
stream.write(vncAuthResponse(challenge, password));
} else if (secType !== SEC_NONE) {
throw new Error(`unsupported VNC security type ${secType}`);
}
// 3.8 always sends SecurityResult; earlier versions only send it for real auth.
if (negotiated >= 8 || secType !== SEC_NONE) {
const result = await r.readU32();
if (result !== 0) {
const reason = negotiated >= 8 ? await r.readReason().catch(() => '') : '';
throw new Error(reason || 'VNC authentication failed (wrong password?)');
}
}
return { version: `${major}.${rawMinor}`, securityType: secType };
}
/**
* Act as a VNC *server* toward the browser, offering "None" security. By the
* time this runs the hub has already authenticated upstream, so the browser is
* handed an already-authorised stream and never sees the real password.
*/
async function handshakeWithBrowser(stream, timeoutMs = 20_000) {
const r = new ByteReader(stream, timeoutMs);
stream.write(Buffer.from('RFB 003.008\n', 'ascii'));
const { minor } = parseVersion(await r.read(12));
if (minor >= 7) {
stream.write(Buffer.from([1, SEC_NONE]));
const chosen = await r.readU8();
if (chosen !== SEC_NONE) {
const reason = Buffer.from('unsupported security type', 'utf8');
const buf = Buffer.alloc(8 + reason.length);
buf.writeUInt32BE(1, 0);
buf.writeUInt32BE(reason.length, 4);
reason.copy(buf, 8);
stream.write(buf);
throw new Error('browser chose an unsupported security type');
}
// SecurityResult: OK
const ok = Buffer.alloc(4);
ok.writeUInt32BE(0, 0);
stream.write(ok);
} else {
// RFB 3.3: the server dictates the security type and sends no SecurityResult.
const buf = Buffer.alloc(4);
buf.writeUInt32BE(SEC_NONE, 0);
stream.write(buf);
}
}
/* ------------------------------------------------------------------------ */
/* View-only enforcement */
/* ------------------------------------------------------------------------ */
// Client-to-server messages that cannot change anything on the remote machine.
// Everything else is dropped for viewers — notably KeyEvent, PointerEvent,
// ClientCutText (paste), SetDesktopSize and xvp (which can power off a host).
const PASSIVE_MESSAGES = new Set([
0, // SetPixelFormat
2, // SetEncodings
3, // FramebufferUpdateRequest
150, // EnableContinuousUpdates
248, // ClientFence
]);
/**
* Length of the client->server message starting at offset 0 of `buf`.
* Returns 0 when more bytes are needed, -1 when the type is unknown (which means
* we can no longer track message boundaries and must drop the connection).
*/
function clientMessageLength(buf) {
const type = buf[0];
switch (type) {
case 0: return 20; // SetPixelFormat
case 2: // SetEncodings
if (buf.length < 4) return 0;
return 4 + 4 * buf.readUInt16BE(2);
case 3: return 10; // FramebufferUpdateRequest
case 4: return 8; // KeyEvent
case 5: return 6; // PointerEvent
case 6: // ClientCutText
if (buf.length < 8) return 0;
// A negative length marks the extended clipboard extension.
return 8 + Math.abs(buf.readInt32BE(4));
case 150: return 10; // EnableContinuousUpdates
case 248: // ClientFence
if (buf.length < 9) return 0;
return 9 + buf[8];
case 250: return 4; // xvp (shutdown/reboot/reset)
case 251: // SetDesktopSize
if (buf.length < 8) return 0;
return 8 + 16 * buf[6];
case 255: // QEMU client message
if (buf.length < 2) return 0;
if (buf[1] === 0) return 12; // QEMU Extended Key Event
return -1;
default:
return -1;
}
}
/**
* Incremental filter for the browser->server direction of a view-only session.
* Feed it chunks; it returns only the bytes that are safe to forward.
*/
class ViewOnlyFilter {
constructor() {
this.pending = Buffer.alloc(0);
this.blocked = 0;
}
push(chunk) {
this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;
const keep = [];
while (this.pending.length > 0) {
const len = clientMessageLength(this.pending);
if (len === -1) {
throw new Error(`unparseable client message type ${this.pending[0]} in view-only session`);
}
if (len === 0 || this.pending.length < len) break;
const msg = this.pending.subarray(0, len);
if (PASSIVE_MESSAGES.has(msg[0])) keep.push(Buffer.from(msg));
else this.blocked++;
this.pending = this.pending.subarray(len);
}
if (!keep.length) return null;
return keep.length === 1 ? keep[0] : Buffer.concat(keep);
}
}
module.exports = {
ByteReader,
handshakeWithServer,
handshakeWithBrowser,
ViewOnlyFilter,
clientMessageLength,
SEC_NONE,
SEC_VNC_AUTH,
};