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>
This commit is contained in:
@@ -5,3 +5,4 @@ test
|
||||
.env
|
||||
*.md
|
||||
!PLAN.md
|
||||
dist
|
||||
|
||||
@@ -3,3 +3,4 @@ data/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
dist/
|
||||
|
||||
+14
@@ -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
|
||||
|
||||
# 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 public ./public
|
||||
COPY agent ./agent
|
||||
COPY --from=agent-exe /build/dist/rcs-agent.exe ./dist/rcs-agent.exe
|
||||
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -119,6 +119,37 @@ node agent.js run [--vnc-host H] [--vnc-port N]
|
||||
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
|
||||
|
||||
+59
-12
@@ -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 = {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"start": "node server/index.js",
|
||||
"dev": "node --watch server/index.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"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
@@ -59,6 +59,22 @@
|
||||
</li>
|
||||
</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">
|
||||
Link expires <span id="expiry"></span> · once enrolled, this machine appears in the operator console
|
||||
</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 win = `iwr ${origin}/download/agent.js -OutFile rcs-agent.js; `
|
||||
+ `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) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
@@ -87,6 +104,8 @@
|
||||
document.getElementById('oneliner').textContent = unix;
|
||||
document.getElementById('copy-unix').onclick = (e) => copy(unix, 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)}`)
|
||||
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
@@ -67,6 +68,20 @@ app.get('/download/agent.js', (_req, res) => {
|
||||
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(express.static(PUBLIC_DIR));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user