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
|
.env
|
||||||
*.md
|
*.md
|
||||||
!PLAN.md
|
!PLAN.md
|
||||||
|
dist
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ data/
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.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
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+53
-6
@@ -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',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
token,
|
token,
|
||||||
hostname: os.hostname(),
|
hostname: os.hostname(),
|
||||||
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
os: `${os.type()} ${os.release()} (${os.arch()})`,
|
||||||
agentVersion: VERSION,
|
agentVersion: VERSION,
|
||||||
vncPort,
|
vncPort,
|
||||||
name: args.name || undefined,
|
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 = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 })))
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user