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>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
# 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 |
|
||||
Reference in New Issue
Block a user