Files
rmancinasandClaude Opus 5 999717f77b 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>
2026-08-11 23:37:35 -07:00

602 lines
24 KiB
JavaScript

'use strict';
// End-to-end test: boots the real hub against a fake garagedoor auth service and
// a fake VNC server, then drives it as a browser would.
//
// Covers the things that are easy to get subtly wrong: server-side VNC
// authentication (the browser must never be asked for a password), view-only
// enforcement at the proxy, and the agent tunnel path.
//
// node test/e2e.js
const http = require('http');
const net = require('net');
const os = require('os');
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const { WebSocket, createWebSocketStream } = require('ws');
const { vncAuthResponse } = require('../server/vnc/des');
const { ByteReader } = require('../server/vnc/rfb');
const VNC_PASSWORD = 'hunter2';
let failures = 0;
let passes = 0;
function check(ok, label, detail) {
if (ok) {
passes++;
console.log(` ok ${label}`);
} else {
failures++;
console.log(` FAIL ${label}${detail ? ` — ${detail}` : ''}`);
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function listen(server, port = 0) {
return new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve(server.address().port)));
}
/* -------------------------------------------------------- fake services */
function startFakeAuth() {
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.url === '/authenticate' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
const { username, password } = JSON.parse(body || '{}');
// garagedoor answers 200 even on failure; the app must read body.result.
if (password === 'correct-horse') {
res.end(JSON.stringify({ statusCode: 200, result: 'success', username, level: 10, token: `tok-${username}` }));
} else {
res.end(JSON.stringify({ statusCode: 401, result: 'failed', message: 'Authentication failed' }));
}
});
return;
}
if (req.url === '/validate') {
const auth = req.headers.authorization || '';
const token = auth.replace('Bearer ', '');
if (token.startsWith('tok-')) {
res.end(JSON.stringify({ statusCode: 200, result: 'success', username: token.slice(4) }));
} else {
res.end(JSON.stringify({ statusCode: 200, result: 'failed' }));
}
return;
}
res.statusCode = 404;
res.end('{}');
});
return server;
}
/**
* A VNC server that demands VNC Authentication, then records every
* client-to-server message it receives after ServerInit.
*/
function startFakeVnc(state) {
const server = net.createServer(async (socket) => {
const r = new ByteReader(socket, 5000);
try {
socket.write(Buffer.from('RFB 003.008\n', 'ascii'));
await r.read(12);
socket.write(Buffer.from([1, 2])); // offer only VNC Authentication
const chosen = await r.readU8();
state.chosenSecurity = chosen;
const challenge = Buffer.alloc(16, 0x5a);
socket.write(challenge);
const response = await r.read(16);
const expected = vncAuthResponse(challenge, VNC_PASSWORD);
state.authOk = response.equals(expected);
const ok = Buffer.alloc(4);
ok.writeUInt32BE(state.authOk ? 0 : 1, 0);
socket.write(ok);
if (!state.authOk) return socket.end();
const shared = await r.read(1);
state.sharedFlag = shared[0];
const name = Buffer.from('fake screen', 'utf8');
const init = Buffer.alloc(24 + name.length);
init.writeUInt16BE(1024, 0);
init.writeUInt16BE(768, 2);
init[4] = 32; init[5] = 24; init[6] = 0; init[7] = 1; // bpp, depth, big-endian, true-colour
init.writeUInt16BE(255, 8); init.writeUInt16BE(255, 10); init.writeUInt16BE(255, 12);
init[14] = 16; init[15] = 8; init[16] = 0; // shifts
init.writeUInt32BE(name.length, 20);
name.copy(init, 24);
socket.write(init);
state.connected = true;
socket.on('data', (chunk) => {
for (const byte of chunk) state.received.push(byte);
state.messageTypes.push(chunk[0]);
});
} catch (err) {
state.error = err.message;
}
});
return server;
}
/* ------------------------------------------------------------ hub client */
class Hub {
constructor(base) {
this.base = base;
this.token = null;
}
async request(method, path, body, { auth = true } = {}) {
const res = await fetch(`${this.base}${path}`, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(auth && this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
return { status: res.status, data };
}
}
/** Speak the browser half of RFB over the hub's WebSocket. */
async function browserSession(base, ticket) {
const ws = new WebSocket(`${base.replace('http', 'ws')}/ws/vnc?ticket=${encodeURIComponent(ticket)}`);
ws.binaryType = 'nodebuffer';
const closed = new Promise((resolve) => {
ws.on('close', (code, reason) => resolve({ code, reason: reason.toString() }));
});
const opened = await new Promise((resolve) => {
ws.on('open', () => resolve(true));
ws.on('close', () => resolve(false));
ws.on('error', () => resolve(false));
});
if (!opened) return { ok: false, closed };
const stream = createWebSocketStream(ws, { allowHalfOpen: false });
stream.on('error', () => { /* the close reason is the useful signal */ });
const r = new ByteReader(stream, 5000);
try {
return await handshakeAsBrowser(r, stream, ws, closed);
} catch (err) {
// A hub-side failure arrives as a close code + reason; surface it.
const info = await Promise.race([closed, sleep(300).then(() => null)]);
return { ok: false, closed, error: info ? `${info.code}: ${info.reason}` : err.message };
}
}
async function handshakeAsBrowser(r, stream, ws, closed) {
const version = await r.read(12);
stream.write(Buffer.from('RFB 003.008\n', 'ascii'));
const count = await r.readU8();
const types = Array.from(await r.read(count));
stream.write(Buffer.from([1])); // None
const securityResult = await r.readU32();
stream.write(Buffer.from([1])); // ClientInit, shared
const head = await r.read(24);
const nameLen = head.readUInt32BE(20);
const name = nameLen ? (await r.read(nameLen)).toString() : '';
return {
ok: true,
ws,
stream,
closed,
version: version.toString().trim(),
securityTypes: types,
securityResult,
width: head.readUInt16BE(0),
height: head.readUInt16BE(2),
name,
};
}
function keyEvent(keysym, down = true) {
const b = Buffer.alloc(8);
b[0] = 4;
b[1] = down ? 1 : 0;
b.writeUInt32BE(keysym, 4);
return b;
}
function framebufferUpdateRequest() {
const b = Buffer.alloc(10);
b[0] = 3;
b[1] = 1;
b.writeUInt16BE(0, 2);
b.writeUInt16BE(0, 4);
b.writeUInt16BE(1024, 6);
b.writeUInt16BE(768, 8);
return b;
}
/* ------------------------------------------------------------------ main */
async function main() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rcs-e2e-'));
const authServer = startFakeAuth();
const authPort = await listen(authServer);
const vncState = { received: [], messageTypes: [] };
const vncServer = startFakeVnc(vncState);
const vncPort = await listen(vncServer);
const hubPort = 18099;
const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
env: {
...process.env,
PORT: String(hubPort),
HOST: '127.0.0.1',
AUTH_URL: `http://127.0.0.1:${authPort}`,
DB_PATH: path.join(tmp, 'test.db'),
ENCRYPTION_KEY: 'test-key-not-a-secret',
ADMIN_USERS: 'alice',
NODE_NO_WARNINGS: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (d) => process.env.VERBOSE && process.stdout.write(` [hub] ${d}`));
child.stderr.on('data', (d) => process.stderr.write(` [hub!] ${d}`));
const base = `http://127.0.0.1:${hubPort}`;
for (let i = 0; i < 60; i++) {
try {
const res = await fetch(`${base}/api/health`);
if (res.ok) break;
} catch { /* not up yet */ }
await sleep(100);
}
const hub = new Hub(base);
let clientId;
try {
console.log('\nauth');
{
const bad = await hub.request('POST', '/api/login', { username: 'alice', password: 'wrong' }, { auth: false });
check(bad.status === 401, 'bad password is a real 401', `got ${bad.status}`);
const good = await hub.request('POST', '/api/login', { username: 'alice', password: 'correct-horse' }, { auth: false });
check(good.status === 200 && !!good.data.token, 'login returns a token');
check(good.data.isAdmin === true, 'ADMIN_USERS makes alice an admin');
hub.token = good.data.token;
const anon = await hub.request('GET', '/api/clients', null, { auth: false });
check(anon.status === 401, 'unauthenticated API access is refused');
}
console.log('\nclients');
{
const created = await hub.request('POST', '/api/clients', {
name: 'test-desk',
mode: 'direct',
host: '127.0.0.1',
port: vncPort,
vncPassword: VNC_PASSWORD,
tags: 'lab, test',
});
check(created.status === 201, 'client created', JSON.stringify(created.data));
clientId = created.data.client?.id;
check(created.data.client?.hasPassword === true, 'client reports a stored password');
check(!('vncPassword' in (created.data.client || {})) && !('vnc_password_enc' in (created.data.client || {})),
'the API never echoes the VNC password back');
const dupe = await hub.request('POST', '/api/clients', { name: 'test-desk', mode: 'direct', host: '127.0.0.1' });
check(dupe.status === 409, 'duplicate names are rejected');
const list = await hub.request('GET', '/api/clients');
check(list.data.clients.length === 1, 'client shows in the list');
}
console.log('\nfull-control session');
{
const ticket = await hub.request('POST', '/api/sessions', { clientId });
check(ticket.status === 200 && !!ticket.data.ticket, 'ticket minted');
check(ticket.data.role === 'admin', 'admin gets the admin role');
const session = await browserSession(base, ticket.data.ticket);
check(session.ok, 'websocket accepted the ticket');
check(session.version === 'RFB 003.008', 'hub speaks RFB 3.8 to the browser');
check(session.securityTypes.length === 1 && session.securityTypes[0] === 1,
'browser is offered "None" security only', JSON.stringify(session.securityTypes));
check(session.securityResult === 0, 'browser gets SecurityResult OK without a password');
check(vncState.chosenSecurity === 2, 'hub chose VNC Authentication upstream');
check(vncState.authOk === true, 'hub answered the DES challenge correctly');
check(session.width === 1024 && session.name === 'fake screen', 'ServerInit passed through');
vncState.messageTypes.length = 0;
session.stream.write(keyEvent(0x41));
session.stream.write(framebufferUpdateRequest());
await sleep(200);
check(vncState.messageTypes.includes(4), 'operator key events reach the VNC server');
const replayed = await hub.request('POST', '/api/sessions', { clientId });
const reused = await browserSession(base, ticket.data.ticket);
check(!reused.ok, 'a ticket cannot be redeemed twice');
check(replayed.status === 200, 'a fresh ticket can still be minted');
session.ws.close();
await sleep(150);
}
console.log('\nview-only enforcement');
{
const ticket = await hub.request('POST', '/api/sessions', { clientId, viewOnly: true });
check(ticket.data.role === 'viewer', 'viewOnly downgrades the session role');
const session = await browserSession(base, ticket.data.ticket);
check(session.ok, 'view-only session connected', session.error);
if (!session.ok) throw new Error(`view-only session failed: ${session.error}`);
vncState.messageTypes.length = 0;
session.stream.write(keyEvent(0x41)); // must be dropped
session.stream.write(Buffer.from([5, 1, 0, 10, 0, 10])); // PointerEvent, must be dropped
session.stream.write(framebufferUpdateRequest()); // must pass
await sleep(250);
check(!vncState.messageTypes.includes(4), 'KeyEvent is blocked by the proxy');
check(!vncState.messageTypes.includes(5), 'PointerEvent is blocked by the proxy');
check(vncState.messageTypes.includes(3), 'FramebufferUpdateRequest still passes');
const live = await hub.request('GET', '/api/sessions/live');
const mine = live.data.sessions.find((s) => s.role === 'viewer');
check(!!mine, 'the live session is listed');
check(mine && mine.blockedInputs >= 2, 'blocked input count is reported', JSON.stringify(mine));
const killed = await hub.request('POST', `/api/sessions/${mine.id}/kill`);
check(killed.status === 200, 'admin can force-disconnect');
const after = await session.closed;
check(after.code === 4008, 'the viewer socket was closed by the hub', `code ${after.code}`);
}
console.log('\naccess control');
{
const bobLogin = await hub.request('POST', '/api/login', { username: 'bob', password: 'correct-horse' }, { auth: false });
const bob = new Hub(base);
bob.token = bobLogin.data.token;
check(bobLogin.data.isAdmin === false, 'bob is not an admin');
const bobList = await bob.request('GET', '/api/clients');
check(bobList.data.clients.length === 0, 'bob sees no machines without a grant');
const denied = await bob.request('POST', '/api/sessions', { clientId });
check(denied.status === 403, 'bob cannot start a session without a grant');
const create = await bob.request('POST', '/api/clients', { name: 'bobs-pc', mode: 'direct', host: '127.0.0.1' });
check(create.status === 403, 'bob cannot create machines');
await hub.request('POST', `/api/clients/${clientId}/grants`, { username: 'bob', role: 'viewer' });
const granted = await bob.request('GET', '/api/clients');
check(granted.data.clients.length === 1, 'the grant makes the machine visible to bob');
const bobTicket = await bob.request('POST', '/api/sessions', { clientId });
check(bobTicket.data.role === 'viewer', 'bob is held to viewer even asking for control');
await hub.request('DELETE', `/api/clients/${clientId}/grants/bob`);
const revoked = await bob.request('GET', '/api/clients');
check(revoked.data.clients.length === 0, 'removing the grant hides it again');
}
console.log('\ninvites and enrolment');
{
const enroll = await hub.request('POST', '/api/invites', { kind: 'enroll', name: 'kiosk', ttlMs: 60_000 });
check(enroll.status === 201 && !!enroll.data.token, 'enrolment invite created');
check(enroll.data.url.includes('/enroll/'), 'enrolment link points at the enrol page');
const info = await fetch(`${base}/api/public/invite/${enroll.data.token}`).then((r) => r.json());
check(info.usable === true && info.kind === 'enroll', 'invite info is readable without a login');
const enrolled = await hub.request('POST', '/api/public/enroll', {
token: enroll.data.token,
hostname: 'kiosk-01',
os: 'Linux 6.1',
agentVersion: '0.1.0',
vncPort: vncPort,
}, { auth: false });
check(enrolled.status === 201 && !!enrolled.data.agentKey, 'machine enrolled and got an agent key');
check(enrolled.data.name === 'kiosk', 'the invite name was applied');
const again = await hub.request('POST', '/api/public/enroll', { token: enroll.data.token, hostname: 'x' }, { auth: false });
check(again.status === 410, 'a single-use enrolment link cannot be reused');
global.agentClientId = enrolled.data.clientId;
global.agentKey = enrolled.data.agentKey;
}
console.log('\nagent tunnel');
{
const clientId = global.agentClientId;
const key = global.agentKey;
const badAgent = new WebSocket(`${base.replace('http', 'ws')}/ws/agent?clientId=${clientId}&key=wrong`);
const badResult = await new Promise((resolve) => {
badAgent.on('open', () => resolve('open'));
badAgent.on('error', () => resolve('rejected'));
});
check(badResult === 'rejected', 'a wrong agent key is refused at upgrade');
// Minimal stand-in for agent/agent.js: hold a control socket, open tunnels on demand.
const control = new WebSocket(`${base.replace('http', 'ws')}/ws/agent?clientId=${clientId}&key=${key}`);
await new Promise((resolve, reject) => {
control.on('open', resolve);
control.on('error', reject);
});
control.send(JSON.stringify({ type: 'hello', version: '0.1.0', os: 'Linux', hostname: 'kiosk-01', vncPort }));
control.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type !== 'open') return;
const tunnel = new WebSocket(
`${base.replace('http', 'ws')}/ws/tunnel?clientId=${clientId}&key=${key}&tunnelId=${msg.tunnelId}`
);
tunnel.binaryType = 'nodebuffer';
tunnel.on('open', () => {
const socket = net.connect({ host: '127.0.0.1', port: vncPort });
socket.on('data', (c) => tunnel.readyState === 1 && tunnel.send(c));
tunnel.on('message', (c) => socket.write(c));
tunnel.on('close', () => socket.destroy());
socket.on('close', () => tunnel.close());
});
});
await sleep(300);
const listed = await hub.request('GET', '/api/clients');
const agentClient = listed.data.clients.find((c) => c.id === clientId);
check(agentClient?.online === true, 'the agent shows as online');
check(agentClient?.hostname === 'kiosk-01', 'the agent reported its hostname');
// The enrolled client has no stored VNC password, so point it at a server
// that does not demand one for this leg of the test.
await hub.request('PATCH', `/api/clients/${clientId}`, { vncPassword: VNC_PASSWORD });
const ticket = await hub.request('POST', '/api/sessions', { clientId });
check(ticket.status === 200, 'ticket minted for the agent client', JSON.stringify(ticket.data));
const session = await browserSession(base, ticket.data.ticket);
check(session.ok, 'session established through the agent tunnel');
check(session.name === 'fake screen', 'framebuffer details came back through the tunnel');
session.ws?.close();
control.close();
await sleep(200);
const offline = await hub.request('GET', '/api/clients');
check(offline.data.clients.find((c) => c.id === clientId)?.online === false,
'the client goes offline when the agent disconnects');
}
console.log('\nthe real agent binary');
{
const invite = await hub.request('POST', '/api/invites', { kind: 'enroll', name: 'agent-test', ttlMs: 60_000 });
const agentConfig = path.join(tmp, 'agent.json');
const agentPath = path.join(__dirname, '..', 'agent', 'agent.js');
const enrolled = await new Promise((resolve) => {
const p = spawn(process.execPath, [
agentPath, 'enroll', invite.data.url,
'--config', agentConfig, '--vnc-port', String(vncPort),
], { env: { ...process.env, NODE_NO_WARNINGS: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
p.stdout.on('data', (d) => { out += d; });
p.stderr.on('data', (d) => { out += d; });
p.on('exit', (code) => resolve({ code, out }));
});
check(enrolled.code === 0, 'agent enroll succeeded', enrolled.out.trim());
check(fs.existsSync(agentConfig), 'agent wrote its config');
const saved = JSON.parse(fs.readFileSync(agentConfig, 'utf8'));
check(!!saved.agentKey && !!saved.clientId, 'config holds the client id and key');
check((fs.statSync(agentConfig).mode & 0o777) === 0o600, 'config file is owner-only');
const agentProc = spawn(process.execPath, [agentPath, 'run', '--config', agentConfig], {
env: { ...process.env, NODE_NO_WARNINGS: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
let agentOut = '';
agentProc.stdout.on('data', (d) => { agentOut += d; });
agentProc.stderr.on('data', (d) => { agentOut += d; });
for (let i = 0; i < 40 && !agentOut.includes('registered as'); i++) await sleep(100);
check(agentOut.includes('registered as'), 'agent connected and registered', agentOut.trim());
await hub.request('PATCH', `/api/clients/${saved.clientId}`, { vncPassword: VNC_PASSWORD });
const ticket = await hub.request('POST', '/api/sessions', { clientId: saved.clientId });
check(ticket.status === 200, 'ticket minted for the real agent', JSON.stringify(ticket.data));
const session = await browserSession(base, ticket.data.ticket);
check(session.ok, 'session ran through the real agent', session.error);
if (session.ok) {
vncState.messageTypes.length = 0;
session.stream.write(framebufferUpdateRequest());
await sleep(250);
check(vncState.messageTypes.includes(3), 'input reached the VNC server through the agent');
session.ws.close();
}
await sleep(200);
agentProc.kill('SIGTERM');
await sleep(200);
}
console.log('\nsupport links');
{
const share = await hub.request('POST', '/api/invites', {
kind: 'session', clientId, role: 'viewer', ttlMs: 60_000, label: 'customer',
});
check(share.status === 201, 'support link created');
check(share.data.invite.maxUses === 0, 'support links default to unlimited uses until expiry');
const joined = await fetch(`${base}/api/public/session/${share.data.token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Dana' }),
}).then((r) => r.json());
check(!!joined.ticket, 'a guest with the link gets a ticket without logging in');
check(joined.role === 'viewer', 'the link fixes the role');
const guest = await browserSession(base, joined.ticket);
check(guest.ok, 'the guest connected');
vncState.messageTypes.length = 0;
guest.stream.write(keyEvent(0x41));
await sleep(200);
check(!vncState.messageTypes.includes(4), 'a guest viewer still cannot send input');
guest.ws?.close();
await hub.request('POST', `/api/invites/${share.data.invite.id}/revoke`);
const afterRevoke = await fetch(`${base}/api/public/session/${share.data.token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
check(afterRevoke.status === 410, 'a revoked link stops working');
const bogus = await fetch(`${base}/api/public/invite/definitely-not-a-real-token`);
check(bogus.status === 404, 'an unknown token is a 404');
}
console.log('\naudit');
{
const { data } = await hub.request('GET', '/api/sessions/audit');
const actions = data.audit.map((a) => a.action);
check(actions.includes('client.create'), 'client creation is audited');
check(actions.includes('session.start'), 'sessions are audited');
check(actions.includes('invite.create'), 'invites are audited');
check(actions.includes('session.kill'), 'force-disconnects are audited');
const history = await hub.request('GET', '/api/sessions/history');
check(history.data.sessions.length >= 4, 'session history persisted', `${history.data.sessions.length} rows`);
check(history.data.sessions.every((s) => s.ended_at), 'closed sessions have an end time');
}
} catch (err) {
failures++;
console.log(`\n FAIL unexpected error: ${err.stack}`);
} finally {
child.kill('SIGTERM');
authServer.close();
vncServer.close();
await sleep(200);
fs.rmSync(tmp, { recursive: true, force: true });
}
console.log(`\n${passes} passed, ${failures} failed\n`);
process.exit(failures ? 1 : 0);
}
main();