Reworks the console's look away from generic dark-mode defaults: the neutral ramp carries a faint violet cast, and the signal palette now distinguishes connection topology by colour — mint for live agents, iris for direct connections, copper for consent-required machines. Type contrast comes from pairing the platform mono against the platform sans rather than a webfont, since the box this runs on has no internet. particles.js draws a proximity-linked node field: full strength on the login and public pages, frozen static in the console, and entirely absent from the viewer, where every frame belongs to the remote desktop. It caps DPR at 2, scales node count to viewport area, throttles to 30fps, cancels rAF when the tab is hidden, and honours prefers-reduced-motion. app.js and viewer.js are unchanged; the particle layer mounts itself and tracks the console via MutationObserver on #app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 |