feat(agent): ship a Node 12 executable for Windows Server 2008 R2
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

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>
This commit is contained in:
2026-08-12 00:55:57 -07:00
co-authored by Claude Opus 5
parent 88be2bf867
commit cecaf74a0a
8 changed files with 141 additions and 12 deletions
+59 -12
View File
@@ -10,13 +10,23 @@
//
// 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');
@@ -39,6 +49,47 @@ 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();
@@ -131,20 +182,16 @@ async function cmdEnroll(args) {
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 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 = await res.json().catch(() => ({}));
const body = res.body;
if (!res.ok) throw new Error(body.error || `enrolment failed (HTTP ${res.status})`);
const cfg = {