Browser-based remote control (noVNC) with invite links, per-user access control, garagedoor SSO and a persisted client list. The hub proxies RFB rather than pointing the browser at a VNC server. That is what lets it authenticate upstream with a stored password the browser never sees, and enforce view-only by dropping input messages on the client->server stream instead of hiding buttons. Machines are reachable two ways: direct TCP for LAN hosts, or an outbound agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the agent dependency-free, and node:sqlite keeps the image free of native builds. Ships with an end-to-end suite that boots the real server against a fake VNC server and a fake auth service (72 assertions), plus Gitea Actions CI/CD to Portainer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.1 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 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
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 |
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 |