Files
remote-control-support-webapp/README.md
T
rmancinasandClaude Opus 5 999717f77b feat: self-hosted remote support over VNC
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>
2026-08-11 23:37:35 -07:00

210 lines
8.1 KiB
Markdown

# 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](PLAN.md) for the architecture and feature checklist.
---
## Quick start
```bash
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):
```bash
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 == 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:
```bash
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:
```bash
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](.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 |