Machines that cannot install Node 22 had no way to run the agent at all, which
left them stuck on direct mode — and direct mode only works when the hub can
route to the VNC port, which it often cannot.
agent.js now resolves the two Node 22 globals it uses through fallbacks: `ws`
for the control and tunnel sockets, and http/https for the single enrolment
POST. Node 22 loads neither, since `globalThis.WebSocket || require('ws')`
short-circuits. The require and the http/https references are static so the
bundler can follow them.
A new Docker stage bundles that file with a Node 12 runtime — the last line
supporting Windows 7 and Server 2008 R2 — into one self-contained .exe, served
from /download/agent.exe and linked from the enrolment page. The route answers
503 rather than 404 in a dev checkout, where the build has not run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
406 lines
13 KiB
JavaScript
406 lines
13 KiB
JavaScript
#!/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`.
|
|
//
|
|
// It also has to run on machines that cannot have Node 22 — Windows Server
|
|
// 2008 R2 tops out at Node 13 — so the two modern globals it leans on are
|
|
// resolved through fallbacks rather than used directly. On Node 22 nothing
|
|
// extra is loaded; on an old runtime it needs the `ws` package alongside it.
|
|
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const net = require('net');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { execFile } = require('child_process');
|
|
|
|
// Short-circuits before `require`, so Node 22 never looks for `ws` at all.
|
|
const WebSocket = globalThis.WebSocket || require('ws');
|
|
|
|
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 });
|
|
}
|
|
|
|
/**
|
|
* POSTs JSON and reads JSON back, the one HTTP call this agent makes. Uses
|
|
* global `fetch` where it exists (Node 18+) and falls back to the http modules
|
|
* otherwise. Resolves to `{ ok, status, body }` either way; a non-JSON or empty
|
|
* response yields `body = {}` so callers can read `body.error` unguarded.
|
|
*/
|
|
function postJson(url, payload) {
|
|
const data = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
|
|
if (typeof fetch === 'function') {
|
|
return fetch(url, { method: 'POST', headers, body: data })
|
|
.then(async (res) => ({
|
|
ok: res.ok,
|
|
status: res.status,
|
|
body: await res.json().catch(() => ({})),
|
|
}));
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const target = new URL(url);
|
|
// Statically referenced, not `require(expr)` — the bundler that builds the
|
|
// legacy Windows executable can only follow literal requires.
|
|
const lib = target.protocol === 'https:' ? https : http;
|
|
const req = lib.request(target, {
|
|
method: 'POST',
|
|
headers: { ...headers, 'Content-Length': data.length },
|
|
}, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
res.on('end', () => {
|
|
let body = {};
|
|
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* not JSON */ }
|
|
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body });
|
|
});
|
|
});
|
|
req.on('error', reject);
|
|
req.end(data);
|
|
});
|
|
}
|
|
|
|
/** 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 <url>');
|
|
|
|
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 postJson(`${hub}/api/public/enroll`, {
|
|
token,
|
|
hostname: os.hostname(),
|
|
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
|
agentVersion: VERSION,
|
|
vncPort,
|
|
name: args.name || undefined,
|
|
});
|
|
|
|
const body = res.body;
|
|
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 <link>" 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 <link|token> [--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();
|