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>
210 lines
8.1 KiB
Markdown
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 |
|