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
+1
View File
@@ -5,3 +5,4 @@ test
.env .env
*.md *.md
!PLAN.md !PLAN.md
dist
+1
View File
@@ -3,3 +3,4 @@ data/
*.log *.log
.env .env
.DS_Store .DS_Store
dist/
+14
View File
@@ -1,3 +1,16 @@
# Machines that cannot run Node 22 still need an agent. Windows Server 2008 R2
# and Windows 7 top out at Node 13, so the same agent.js is bundled with a Node
# 12 runtime into a single .exe the hub serves from /download/agent.exe.
FROM node:22-alpine AS agent-exe
WORKDIR /build
COPY agent ./agent
# `ws` stands in for the global WebSocket the old runtime does not have; pkg
# follows the literal require in agent.js and bundles it.
RUN npm install --no-save --no-audit --no-fund ws@8.18.0 \
&& npx --yes pkg@5.8.1 agent/agent.js \
--targets node12-win-x64 \
--output dist/rcs-agent.exe
FROM node:22-alpine FROM node:22-alpine
# node:sqlite is built into Node 22, so there are no native modules to compile # node:sqlite is built into Node 22, so there are no native modules to compile
@@ -15,6 +28,7 @@ RUN pnpm install --prod --frozen-lockfile
COPY server ./server COPY server ./server
COPY public ./public COPY public ./public
COPY agent ./agent COPY agent ./agent
COPY --from=agent-exe /build/dist/rcs-agent.exe ./dist/rcs-agent.exe
VOLUME ["/data"] VOLUME ["/data"]
EXPOSE 8080 EXPOSE 8080
+31
View File
@@ -119,6 +119,37 @@ node agent.js run [--vnc-host H] [--vnc-port N]
node agent.js status node agent.js status
``` ```
### Machines that cannot run Node 22
Node 14 dropped Windows 7 and Server 2008 R2; nothing current will install there.
`GET /download/agent.exe` serves the same `agent.js` bundled with a Node 12 runtime
into one self-contained file — the enrolment page links it. Same commands, no
install:
```
rcs-agent.exe enroll https://your-hub/enroll/TOKEN
rcs-agent.exe run
```
The executable is built by the Docker `agent-exe` stage, so CI produces it and a
dev checkout does not — the route answers 503 rather than 404 when it is missing.
Build it locally with `pnpm build:agent-exe` (needs `pkg` on PATH).
Two things this costs. Node 12 has neither global `fetch` nor global `WebSocket`,
so `agent/agent.js` resolves both through fallbacks (`ws`, and `http`/`https` for
the one enrolment POST) — that path is exercised by driving the packaged binary,
not just the source. And Node 12 is long unpatched: it is the client half of a
TLS connection to the hub and nothing else, but it is worth knowing.
If the old machine has a VNC server but you would rather not put a binary on it at
all, run the agent on any modern machine on the same LAN and point it across with
`--vnc-host`. The agent does not have to live on the machine it serves.
Running it as a Windows service (NSSM, `sc create`) puts it in session 0, where it
cannot draw on the interactive desktop — the *ask first* consent dialog will never
appear and the session will time out. On a machine with consent enabled, start the
agent from Task Scheduler **at logon** instead, so it shares the console session.
--- ---
## Access control ## Access control
+59 -12
View File
@@ -10,13 +10,23 @@
// //
// Deliberately dependency-free: Node 22 ships a global WebSocket, so this file // Deliberately dependency-free: Node 22 ships a global WebSocket, so this file
// can be copied onto a machine and run with nothing but `node`. // 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 fs = require('fs');
const http = require('http');
const https = require('https');
const net = require('net'); const net = require('net');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { execFile } = require('child_process'); 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 VERSION = '0.1.0';
const DEFAULT_CONFIG = process.env.RCS_AGENT_CONFIG const DEFAULT_CONFIG = process.env.RCS_AGENT_CONFIG
|| path.join(os.homedir(), '.rcs-agent.json'); || 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 }); 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. */ /** Accepts either a full enrolment URL or a bare token. */
function parseInvite(input) { function parseInvite(input) {
const raw = String(input || '').trim(); 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 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 vncHost = String(args['vnc-host'] || process.env.RCS_VNC_HOST || '127.0.0.1');
const res = await fetch(`${hub}/api/public/enroll`, { const res = await postJson(`${hub}/api/public/enroll`, {
method: 'POST', token,
headers: { 'Content-Type': 'application/json' }, hostname: os.hostname(),
body: JSON.stringify({ os: `${os.type()} ${os.release()} (${os.arch()})`,
token, agentVersion: VERSION,
hostname: os.hostname(), vncPort,
os: `${os.type()} ${os.release()} (${os.arch()})`, name: args.name || undefined,
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})`); if (!res.ok) throw new Error(body.error || `enrolment failed (HTTP ${res.status})`);
const cfg = { const cfg = {
+1
View File
@@ -7,6 +7,7 @@
"start": "node server/index.js", "start": "node server/index.js",
"dev": "node --watch server/index.js", "dev": "node --watch server/index.js",
"agent": "node agent/agent.js", "agent": "node agent/agent.js",
"build:agent-exe": "pkg agent/agent.js --targets node12-win-x64 --output dist/rcs-agent.exe",
"test": "node test/e2e.js" "test": "node test/e2e.js"
}, },
"license": "MIT", "license": "MIT",
+19
View File
@@ -59,6 +59,22 @@
</li> </li>
</ol> </ol>
<div class="notice" style="margin-top:14px">
<strong>On Windows 7 or Server 2008 R2?</strong>
<div class="faint" style="margin-top:6px">
Node.js will not install on those — the last version that supported them is long
out of date. Use the standalone build instead: one file, nothing to install.
</div>
<div class="row wrap" style="margin-top:10px">
<a class="small" href="/download/agent.exe" download="rcs-agent.exe">Download rcs-agent.exe</a>
</div>
<div class="faint" style="margin-top:10px">Then in a Command Prompt, in the folder you saved it to:</div>
<pre class="code" id="exe-oneliner"></pre>
<div class="row wrap" style="margin-top:10px">
<button class="small" id="copy-exe">Copy</button>
</div>
</div>
<p class="login-foot"> <p class="login-foot">
Link expires <span id="expiry"></span> · once enrolled, this machine appears in the operator console Link expires <span id="expiry"></span> · once enrolled, this machine appears in the operator console
</p> </p>
@@ -75,6 +91,7 @@
const unix = `curl -fsSL ${origin}/download/agent.js -o rcs-agent.js \\\n && node rcs-agent.js enroll ${link} \\\n && node rcs-agent.js run`; const unix = `curl -fsSL ${origin}/download/agent.js -o rcs-agent.js \\\n && node rcs-agent.js enroll ${link} \\\n && node rcs-agent.js run`;
const win = `iwr ${origin}/download/agent.js -OutFile rcs-agent.js; ` const win = `iwr ${origin}/download/agent.js -OutFile rcs-agent.js; `
+ `node rcs-agent.js enroll ${link}; node rcs-agent.js run`; + `node rcs-agent.js enroll ${link}; node rcs-agent.js run`;
const exe = `rcs-agent.exe enroll ${link}\r\nrcs-agent.exe run`;
function copy(text, button) { function copy(text, button) {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
@@ -87,6 +104,8 @@
document.getElementById('oneliner').textContent = unix; document.getElementById('oneliner').textContent = unix;
document.getElementById('copy-unix').onclick = (e) => copy(unix, e.currentTarget); document.getElementById('copy-unix').onclick = (e) => copy(unix, e.currentTarget);
document.getElementById('copy-win').onclick = (e) => copy(win, e.currentTarget); document.getElementById('copy-win').onclick = (e) => copy(win, e.currentTarget);
document.getElementById('exe-oneliner').textContent = exe;
document.getElementById('copy-exe').onclick = (e) => copy(exe, e.currentTarget);
fetch(`/api/public/invite/${encodeURIComponent(token)}`) fetch(`/api/public/invite/${encodeURIComponent(token)}`)
.then((r) => r.json().then((body) => ({ ok: r.ok, body }))) .then((r) => r.json().then((body) => ({ ok: r.ok, body })))
+15
View File
@@ -1,5 +1,6 @@
'use strict'; 'use strict';
const fs = require('fs');
const http = require('http'); const http = require('http');
const path = require('path'); const path = require('path');
const express = require('express'); const express = require('express');
@@ -67,6 +68,20 @@ app.get('/download/agent.js', (_req, res) => {
res.sendFile(path.join(__dirname, '..', 'agent', 'agent.js')); res.sendFile(path.join(__dirname, '..', 'agent', 'agent.js'));
}); });
// Windows Server 2008 R2 (and Windows 7) cannot install any Node the agent
// would run on — Node 14 dropped them. `pnpm build:agent-exe` bundles the same
// agent.js with a Node 12 runtime into one file that needs nothing installed.
// Built in CI, so a dev checkout will not have it; say so rather than 404.
const AGENT_EXE = path.join(__dirname, '..', 'dist', 'rcs-agent.exe');
app.get('/download/agent.exe', (_req, res) => {
if (!fs.existsSync(AGENT_EXE)) {
return res.status(503).type('text/plain')
.send('the Windows agent executable was not built into this deployment (pnpm build:agent-exe)');
}
res.download(AGENT_EXE, 'rcs-agent.exe');
});
app.use('/novnc', express.static(NOVNC_DIR, { maxAge: '7d', immutable: true })); app.use('/novnc', express.static(NOVNC_DIR, { maxAge: '7d', immutable: true }));
app.use(express.static(PUBLIC_DIR)); app.use(express.static(PUBLIC_DIR));