rmancinasandClaude Opus 5 cecaf74a0a
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
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>
2026-08-12 00:55:57 -07:00

Remote Control Support

Self-hosted remote support over VNC. A TeamViewer-shaped console that runs in the browser: pick a machine, get its screen, take over the mouse and keyboard.

  • noVNC in the browser — nothing to install for the person giving support.
  • Two ways to reach a machine — connect straight to a VNC server on the LAN, or have the machine run a small agent that dials out, so NAT and firewalls stop mattering.
  • Invite links — one kind adds a machine, the other hands someone time-limited access to a single machine with no account at all.
  • Access control that is actually enforced — view-only is applied at the proxy by dropping input messages, not by hiding buttons. Stored VNC passwords never reach the browser.
  • Persisted — machines, grants, invites, session history and an audit log live in SQLite.
  • Login delegated to the existing garagedoor-node-ws auth service.

See PLAN.md for the architecture and feature checklist.


Quick start

pnpm install
cp .env.example .env      # set ADMIN_USERS at minimum
pnpm start                # http://localhost:8080

Run the test suite (boots the real server against a fake VNC server and a fake auth service, then drives it as a browser would):

pnpm test

Deploy

CI/CD is Gitea Actions → registry → Portainer, same shape as the other services here. Pushing to main runs the test suite, builds git.mancinas.io/rmancinas/remote-control-support-webapp:latest, and deploys the stack (.gitea/workflows/deploy.yml).

Gitea repo secrets required:

Secret Value
REGISTRY_USERNAME / REGISTRY_PASSWORD git.mancinas.io login
PORTAINER_URL https://192.168.4.212:9443
PORTAINER_API_KEY Portainer → user icon → Access tokens
PORTAINER_ENDPOINT_ID 2 (the local Swarm endpoint)
PORTAINER_STACK_NAME e.g. remote-control-support
AUTH_URL http://192.168.4.208:8000
ADMIN_USERS e.g. rmancinas
ENCRYPTION_KEY openssl rand -hex 32set this, see below
PUBLIC_URL e.g. https://remote.mancinas.dev

Two things the compose file must keep, both because state lives in the process and on one node:

  • replicas: 1. Agent control sockets and live sessions are held in memory. A second replica would not see the first one's agents, and connects would fail at random depending on which task the browser landed on.
  • node.role == manager placement + named volume. The SQLite volume is node-local. If the task reschedules elsewhere it comes up with an empty database.

ENCRYPTION_KEY is worth setting explicitly rather than letting the container generate one: the generated key lives in the same volume as the database, so losing the volume loses both, and the stored VNC passwords with them.

Locally:

docker build -t rcs . && docker run --rm -p 8080:8080 -v rcs_data:/data rcs

Put it behind HTTPS before using it for real: RFB is a raw framebuffer stream and ws:// sends it in the clear. The lan-https-host setup covers this — and Nginx Proxy Manager in front is also what supplies the real client IP, since the app runs with TRUST_PROXY=true. Enable WebSocket support on the proxy host or nothing connects.


Adding a machine

Direct (the hub can reach its VNC port)

Add machine → host, port, VNC password. Good for servers and desktops on the same LAN as the hub.

Agent (the machine dials out)

Invite a machine produces a link. Open it on the target machine and it shows a one-liner:

curl -fsSL https://your-hub/download/agent.js -o rcs-agent.js \
  && node rcs-agent.js enroll https://your-hub/enroll/TOKEN \
  && node rcs-agent.js run

The agent needs Node 22+ and a VNC server listening on 127.0.0.1:5900:

OS VNC server
macOS System Settings → General → Sharing → Screen Sharing
Windows TightVNC or UltraVNC
Linux x11vnc -localhost -rfbport 5900

Enrolment issues an agent key, stored hashed on the hub and written to ~/.rcs-agent.json (mode 600) on the machine. To keep it running, wrap node rcs-agent.js run in a systemd unit, a launchd plist, or a scheduled task.

Agent commands:

node agent.js enroll <link|token> [--hub URL] [--vnc-host H] [--vnc-port N] [--name NAME]
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

Role Gets
admin Everything: machines, invites, grants, audit, force-disconnect
operator Full keyboard and mouse on machines they were granted
viewer Screen only — input is dropped by the proxy

Admins are set by ADMIN_USERS (comma-separated usernames) or ADMIN_LEVEL (garagedoor level threshold). With neither set, every authenticated user is an admin — fine for a single operator, wrong for a shared install.

Everyone else sees only machines they hold a grant for (⋯ → Who has access).

Turning on ask first for a machine makes its agent prompt whoever is sitting there before each session, and the session does not start until they accept.

Support link creates a URL that grants one machine, one role, until it expires. The person opening it types a name and connects — no account. Revoke it from the Invites tab at any time; live sessions can be cut from the Sessions tab.


How a session actually works

browser ──wss /ws/vnc?ticket=…──► hub ──tcp──► VNC server        (direct)
browser ──wss /ws/vnc?ticket=…──► hub ◄─wss /ws/tunnel── agent ──tcp──► VNC server   (agent)

The hub is a deliberate man-in-the-middle. It completes the RFB handshake with the real VNC server itself — including VNC Authentication, using the password it holds encrypted — and then presents the browser a handshake that needs no password. Because it sits in the middle of the message stream it can also parse the browser→server direction and drop KeyEvent, PointerEvent, ClientCutText, SetDesktopSize and xvp for view-only sessions.

WebSockets cannot carry an Authorization header, so POST /api/sessions mints a single-use ticket that expires in 30 seconds and the socket carries only that.

Node's OpenSSL 3 build dropped des-ecb from the default provider, so server/vnc/des.js carries a small DES implementation purely to answer the VNC auth challenge. It is verified against the standard test vectors in the test suite.


Configuration

Everything is environment variables — see .env.example.

Variable Default Notes
PORT / HOST 8080 / 0.0.0.0
AUTH_URL http://192.168.4.208:8000 garagedoor-node-ws
ADMIN_USERS (empty) Comma-separated. Empty + no ADMIN_LEVEL = everyone is admin
ADMIN_LEVEL (unset) garagedoor level at or above this is admin
DB_PATH ./data/rcs.db
ENCRYPTION_KEY (generated) Encrypts stored VNC passwords. Back it up
PUBLIC_URL (request host) Base URL used when rendering invite links
ALLOW_SESSION_INVITES true false disables no-login support links
TICKET_TTL_MS 30000
INVITE_TTL_MS 86400000 Default invite lifetime
CONSENT_TIMEOUT_MS 45000 How long to wait for someone to accept

API sketch

Method Path
POST /api/login → token
GET /api/clients machines you can see
POST /api/clients admin
POST /api/clients/:id/grants admin
POST /api/clients/:id/agent-key admin, returns the key once
POST /api/invites admin, kind: enroll | session
POST /api/sessions mint a connect ticket
GET /api/sessions/live who is connected now
POST /api/sessions/:id/kill admin
GET /api/sessions/history
GET /api/sessions/audit admin
POST /api/public/enroll no auth, enrolment token
POST /api/public/session/:token no auth, support link
WS /ws/vnc?ticket= browser session
WS /ws/agent?clientId=&key= agent control channel
WS /ws/tunnel?clientId=&key=&tunnelId= agent data tunnel
S
Description
No description provided
Readme
214 KiB
Languages
JavaScript 68.2%
HTML 14.8%
CSS 12.3%
Shell 2.4%
PowerShell 1.8%
Other 0.5%