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>
214 lines
6.3 KiB
JavaScript
214 lines
6.3 KiB
JavaScript
'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 };
|