Files
remote-control-support-webapp/README.md
T
rmancinasandClaude Opus 5 cfda6b19b7 feat(install): one-command device registration for macOS, Windows and Linux
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>
2026-08-12 11:37:12 -07:00

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 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 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 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