diff --git a/.dockerignore b/.dockerignore index 5603323..f9664f6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,4 @@ test .env *.md !PLAN.md +dist diff --git a/.gitignore b/.gitignore index e3ad628..0e07e23 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ data/ *.log .env .DS_Store +dist/ diff --git a/Dockerfile b/Dockerfile index eff5e67..96caf40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index fee98e7..015510b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/agent/agent.js b/agent/agent.js index e450f57..c19dc82 100644 --- a/agent/agent.js +++ b/agent/agent.js @@ -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 = { diff --git a/package.json b/package.json index e4f834e..48b9fb0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/enroll.html b/public/enroll.html index 292b282..614eaf9 100644 --- a/public/enroll.html +++ b/public/enroll.html @@ -59,6 +59,22 @@ +
+ On Windows 7 or Server 2008 R2? +
+ 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. +
+
+ Download rcs-agent.exe +
+
Then in a Command Prompt, in the folder you saved it to:
+

+          
+ +
+
+

Link expires · once enrolled, this machine appears in the operator console

@@ -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 }))) diff --git a/server/index.js b/server/index.js index 3304dc8..3680a8a 100644 --- a/server/index.js +++ b/server/index.js @@ -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));