'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();