Registering a machine meant reading a multi-step page, installing Node, and running three commands in the right order. Now it is one line per platform. The hub serves scripts/install.sh and scripts/install.ps1 with its own address and the enrolment token substituted in, so the published command carries everything and there is nothing to fill in: curl -fsSL https://support.freakma.com/install.sh?token=TOKEN | sh irm https://support.freakma.com/install.ps1?token=TOKEN | iex That output is piped straight into a shell, so the token — the only untrusted value in either file — is refused unless it matches the base64url shape that randomToken produces. Each script checks for a usable runtime and stops with instructions rather than guessing, warns when nothing is serving RFB on the loopback, installs per-user with no root or administrator, and registers a login-scoped service: launchd on macOS, a lingering systemd user service on Linux, a logon task on Windows. The Windows script uses Node 22 when it is present and falls back to the bundled executable otherwise, which is what lets one command cover both Windows 11 and Server 2008 R2. It registers a logon task rather than a service on purpose: services run in session 0 and cannot draw on the interactive desktop, so the "ask first" consent prompt would never appear. Also adds /docs — a per-OS setup guide with service management and a troubleshooting table — and reworks the enrolment page into OS tabs that open on whichever platform the reader is sitting at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
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 32 — set 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 == managerplacement + 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 the
command with the token already in it. The full walkthrough — per OS, with service
management and troubleshooting — is served at
/docs.
Registering a device
One command per platform. The hub substitutes its own address and the enrolment token
into the script before serving it, so there is nothing to fill in but TOKEN.
macOS and Linux
curl -fsSL https://support.freakma.com/install.sh?token=TOKEN | sh
Windows
irm https://support.freakma.com/install.ps1?token=TOKEN | iex
Windows 7 / Server 2008 R2 — PowerShell 2.0 has no irm, and .NET 3.5 does not
negotiate TLS 1.2 without KB3154518, so
the short form fails twice over. From cmd.exe:
powershell -c "[Net.ServicePointManager]::SecurityProtocol=3072; (New-Object Net.WebClient).DownloadString('https://support.freakma.com/install.ps1?token=TOKEN') | iex"
What the scripts do, in order: check for a usable runtime and stop with instructions
rather than guessing; warn if nothing is listening on 127.0.0.1:5900; download the
agent into a per-user directory; enrol, exchanging the one-time token for an agent key
written 0600; and register a login-scoped service.
| Platform | Installs to | Kept alive by |
|---|---|---|
| macOS | ~/.rcs-agent |
launchd agent, com.freakma.rcs-agent |
| Linux | ~/.rcs-agent |
systemd user service rcs-agent, with lingering enabled |
| Windows | %LOCALAPPDATA%\RemoteControlSupport |
logon task RemoteControlSupportAgent |
Nothing needs root or administrator. On Windows the installer uses Node 22 if it is
already there and falls back to the self-contained rcs-agent.exe otherwise, so the
same command works on a fresh Windows 11 box and on Server 2008 R2.
The Windows installer deliberately registers a logon task, not a service: services run in session 0 and cannot draw on the interactive desktop, so the ask first consent prompt would never appear and every session would time out waiting for an answer.
Both scripts are plain text and worth reading before piping them into a shell —
/install.sh and
/install.ps1 are exactly what runs.
Doing it by hand
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 links
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 |
GET |
/docs |
setup guide, no auth |
GET |
/download/agent.js · /download/agent.exe |
no auth |
GET |
/install.sh · /install.ps1 |
no auth, ?token= is stamped in |
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 |