#!/usr/bin/env node 'use strict'; // Remote-control support agent. // // Runs on the machine being supported. Dials *out* to the hub and keeps a // control WebSocket open, so the machine never needs an inbound port or a // public address. When the hub asks for a session, the agent opens a second // WebSocket and pipes it to the VNC server listening on localhost. // // Deliberately dependency-free: Node 22 ships a global WebSocket, so this file // can be copied onto a machine and run with nothing but `node`. const fs = require('fs'); const net = require('net'); const os = require('os'); const path = require('path'); const { execFile } = require('child_process'); const VERSION = '0.1.0'; const DEFAULT_CONFIG = process.env.RCS_AGENT_CONFIG || path.join(os.homedir(), '.rcs-agent.json'); const RECONNECT_MIN_MS = 2_000; const RECONNECT_MAX_MS = 60_000; /* ----------------------------------------------------------------- utils */ function log(...args) { console.log(new Date().toISOString(), '[agent]', ...args); } function readConfig(file = DEFAULT_CONFIG) { if (!fs.existsSync(file)) return null; return JSON.parse(fs.readFileSync(file, 'utf8')); } function writeConfig(cfg, file = DEFAULT_CONFIG) { fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 }); } /** Accepts either a full enrolment URL or a bare token. */ function parseInvite(input) { const raw = String(input || '').trim(); if (!raw) throw new Error('an enrolment link or token is required'); if (/^https?:\/\//i.test(raw)) { const url = new URL(raw); const token = url.pathname.split('/').filter(Boolean).pop(); if (!token) throw new Error(`cannot find a token in ${raw}`); return { hub: `${url.protocol}//${url.host}`, token }; } return { hub: null, token: raw }; } function wsBase(hub) { return hub.replace(/^http/i, 'ws').replace(/\/$/, ''); } function parseArgs(argv) { const out = { _: [] }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith('--')) { const key = a.slice(2); const next = argv[i + 1]; if (next === undefined || next.startsWith('--')) out[key] = true; else { out[key] = next; i++; } } else { out._.push(a); } } return out; } /* --------------------------------------------------------------- consent */ /** * Ask the person sitting at this machine whether to allow the session. * Falls back to denying if no dialog tool is available — failing closed is the * right default when the whole point of the flag is that a human must agree. */ function askConsent({ operator, role, timeoutMs = 40_000 }) { const message = `${operator} wants to ${role === 'viewer' ? 'view' : 'control'} this computer.\n\nAllow the connection?`; const attempt = (cmd, args) => new Promise((resolve) => { const child = execFile(cmd, args, { timeout: timeoutMs }, (err) => resolve(!err)); child.on('error', () => resolve(null)); // tool missing — try the next one }); if (process.platform === 'darwin') { return attempt('osascript', [ '-e', `display dialog ${JSON.stringify(message)} with title "Remote Support" buttons {"Deny","Allow"} default button "Allow" giving up after ${Math.floor(timeoutMs / 1000)}`, '-e', 'if button returned of result is not "Allow" then error number 1', ]); } if (process.platform === 'win32') { const ps = `Add-Type -AssemblyName PresentationFramework;` + `$r=[System.Windows.MessageBox]::Show(${JSON.stringify(message)},'Remote Support','YesNo','Question');` + `if($r -ne 'Yes'){exit 1}`; return attempt('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]); } // Linux: try the common dialog helpers in turn. return (async () => { for (const [cmd, args] of [ ['zenity', ['--question', '--title=Remote Support', `--text=${message}`, `--timeout=${Math.floor(timeoutMs / 1000)}`]], ['kdialog', ['--title', 'Remote Support', '--yesno', message]], ]) { const result = await attempt(cmd, args); if (result !== null) return result; } log('consent required but no dialog tool (zenity/kdialog) is installed — denying'); return false; })(); } /* --------------------------------------------------------------- enroll */ async function cmdEnroll(args) { const { hub: linkHub, token } = parseInvite(args._[0]); const hub = (args.hub || linkHub || process.env.RCS_HUB || '').replace(/\/$/, ''); if (!hub) throw new Error('cannot tell which hub to enrol with — pass a full link or --hub '); const vncPort = Number(args['vnc-port'] || process.env.RCS_VNC_PORT || 5900); const vncHost = String(args['vnc-host'] || process.env.RCS_VNC_HOST || '127.0.0.1'); const res = await fetch(`${hub}/api/public/enroll`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, hostname: os.hostname(), os: `${os.type()} ${os.release()} (${os.arch()})`, agentVersion: VERSION, vncPort, name: args.name || undefined, }), }); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error || `enrolment failed (HTTP ${res.status})`); const cfg = { hub, clientId: body.clientId, agentKey: body.agentKey, name: body.name, vncHost, vncPort, autoAccept: !body.requireConsent, }; const file = args.config || DEFAULT_CONFIG; writeConfig(cfg, file); log(`enrolled as "${body.name}"`); log(`config written to ${file}`); log(`consent prompt: ${body.requireConsent ? 'on' : 'off'}`); log('start the agent with: node agent.js run'); } /* ------------------------------------------------------------------ run */ class Agent { constructor(cfg) { this.cfg = cfg; this.backoff = RECONNECT_MIN_MS; this.ws = null; this.stopping = false; } start() { this.connect(); process.on('SIGINT', () => this.stop()); process.on('SIGTERM', () => this.stop()); } stop() { this.stopping = true; if (this.ws) try { this.ws.close(); } catch { /* already gone */ } process.exit(0); } connect() { const { hub, clientId, agentKey } = this.cfg; const url = `${wsBase(hub)}/ws/agent?clientId=${encodeURIComponent(clientId)}&key=${encodeURIComponent(agentKey)}`; log(`connecting to ${hub}`); const ws = new WebSocket(url); this.ws = ws; ws.addEventListener('open', () => { this.backoff = RECONNECT_MIN_MS; log('connected'); ws.send(JSON.stringify({ type: 'hello', version: VERSION, os: `${os.type()} ${os.release()} (${os.arch()})`, hostname: os.hostname(), vncPort: this.cfg.vncPort, })); }); ws.addEventListener('message', (event) => { let msg; try { msg = JSON.parse(typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString()); } catch { return; } this.onMessage(msg).catch((err) => log('message handler failed:', err.message)); }); ws.addEventListener('close', (event) => { this.ws = null; if (this.stopping) return; // 4001/4003 mean the hub deliberately dropped us; still retry, more slowly. log(`disconnected (${event.code}${event.reason ? `: ${event.reason}` : ''}), retrying in ${Math.round(this.backoff / 1000)}s`); setTimeout(() => this.connect(), this.backoff); this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS); }); ws.addEventListener('error', () => { /* close always follows */ }); } async onMessage(msg) { switch (msg.type) { case 'welcome': log(`registered as "${msg.name}"${msg.requireConsent ? ' (consent required)' : ''}`); this.requireConsent = !!msg.requireConsent; break; case 'open': { const needsConsent = msg.requireConsent && !this.cfg.autoAccept; if (needsConsent) { log(`${msg.operator} is requesting ${msg.role} access — prompting`); const allowed = await askConsent({ operator: msg.operator, role: msg.role }); if (!allowed) { log('connection denied at the client'); this.send({ type: 'denied', tunnelId: msg.tunnelId, reason: 'the person at that machine declined' }); return; } } this.openTunnel(msg); break; } case 'session-ended': break; default: break; } } send(obj) { if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(obj)); } openTunnel(msg) { const { hub, clientId, agentKey, vncHost, vncPort } = this.cfg; const url = `${wsBase(hub)}/ws/tunnel?clientId=${encodeURIComponent(clientId)}` + `&key=${encodeURIComponent(agentKey)}&tunnelId=${encodeURIComponent(msg.tunnelId)}`; const socket = net.connect({ host: vncHost || '127.0.0.1', port: vncPort || 5900 }); socket.setNoDelay(true); const tunnel = new WebSocket(url); tunnel.binaryType = 'arraybuffer'; let closed = false; const shutdown = (why) => { if (closed) return; closed = true; log(`session ended (${why})`); try { socket.destroy(); } catch { /* already gone */ } try { tunnel.close(); } catch { /* already gone */ } }; socket.on('error', (err) => { log(`cannot reach the local VNC server at ${vncHost}:${vncPort} — ${err.code || err.message}`); this.send({ type: 'error', tunnelId: msg.tunnelId, message: `no VNC server on ${vncHost}:${vncPort}` }); shutdown('local VNC error'); }); socket.on('close', () => shutdown('VNC server closed')); tunnel.addEventListener('open', () => { log(`session started for ${msg.operator} (${msg.role})`); socket.on('data', (chunk) => { if (tunnel.readyState === 1) tunnel.send(chunk); }); }); tunnel.addEventListener('message', (event) => { const data = typeof event.data === 'string' ? Buffer.from(event.data) : Buffer.from(event.data); socket.write(data); }); tunnel.addEventListener('close', () => shutdown('hub closed the tunnel')); tunnel.addEventListener('error', () => shutdown('tunnel error')); } } function cmdRun(args) { const file = args.config || DEFAULT_CONFIG; const cfg = readConfig(file); if (!cfg) throw new Error(`no agent config at ${file} — run "node agent.js enroll " first`); if (args['vnc-port']) cfg.vncPort = Number(args['vnc-port']); if (args['vnc-host']) cfg.vncHost = String(args['vnc-host']); log(`agent ${VERSION}, hub ${cfg.hub}, VNC ${cfg.vncHost || '127.0.0.1'}:${cfg.vncPort || 5900}`); new Agent(cfg).start(); } function cmdStatus(args) { const file = args.config || DEFAULT_CONFIG; const cfg = readConfig(file); if (!cfg) { console.log(`not enrolled (no config at ${file})`); process.exitCode = 1; return; } console.log(JSON.stringify({ ...cfg, agentKey: '***' }, null, 2)); } const USAGE = `remote-control-support agent ${VERSION} node agent.js enroll [--hub URL] [--vnc-host H] [--vnc-port N] [--name NAME] node agent.js run [--vnc-host H] [--vnc-port N] node agent.js status --config PATH agent config file (default ${DEFAULT_CONFIG}) `; async function main() { const args = parseArgs(process.argv.slice(2)); const cmd = args._.shift(); try { if (cmd === 'enroll') await cmdEnroll(args); else if (cmd === 'run') cmdRun(args); else if (cmd === 'status') cmdStatus(args); else { console.log(USAGE); process.exitCode = cmd ? 1 : 0; } } catch (err) { console.error(`error: ${err.message}`); process.exitCode = 1; } } main();