Files
remote-control-support-webapp/agent/agent.js
T
rmancinasandClaude Opus 5 cecaf74a0a
Build and Deploy Remote Control Support / Test (push) Successful in 10s
Build and Deploy Remote Control Support / Build Image (push) Successful in 37s
Build and Deploy Remote Control Support / Deploy to Portainer (push) Successful in 6s
feat(agent): ship a Node 12 executable for Windows Server 2008 R2
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>
2026-08-12 00:55:57 -07:00

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