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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user