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,170 @@
|
||||
# Remote Control Support Webapp — Plan
|
||||
|
||||
Self-hosted TeamViewer-style remote support tool built on **VNC/RFB**, browser-based
|
||||
(noVNC), with invite links, access control, garagedoor SSO, and a persisted client list.
|
||||
|
||||
Status legend: `[ ]` todo · `[~]` in progress · `[x]` done
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```
|
||||
browser (noVNC) hub (this app) client machine
|
||||
┌───────────────────────┐ ┌────────────────────────┐ ┌───────────────────┐
|
||||
│ viewer.html │ wss │ express + ws │ │ VNC server :5900 │
|
||||
│ RFB over WebSocket │◄──────►│ /vnc?ticket=… │ │ │
|
||||
└───────────────────────┘ │ │ │ │
|
||||
│ bridge: │ tcp │ │
|
||||
│ direct mode ─────────┼───────►│ │
|
||||
│ │ │ │
|
||||
│ agent mode │ wss │ agent.js │
|
||||
│ /agent (control) ◄───┼────────┤ outbound only │
|
||||
│ /tunnel (data) ◄───┼────────┤ pipes to :5900 │
|
||||
└────────────────────────┘ └───────────────────┘
|
||||
│
|
||||
SQLite (clients, grants,
|
||||
invites, sessions, audit)
|
||||
```
|
||||
|
||||
**Two connection modes** — a client row is one or the other:
|
||||
|
||||
| Mode | How | Use for |
|
||||
|---|---|---|
|
||||
| `direct` | Hub opens TCP to `host:port`. | LAN machines with a reachable VNC server. |
|
||||
| `agent` | Client runs `agent/agent.js`, dials **out** to the hub over WSS and holds it open. Hub asks it to open a data tunnel per session. | NAT'd / remote machines. The TeamViewer-shaped path. |
|
||||
|
||||
**Why a proxy and not raw noVNC:** the hub terminates the RFB handshake itself. That
|
||||
lets it (a) authenticate to the real VNC server with a **server-side stored password**
|
||||
the browser never sees, and (b) enforce **view-only** by dropping input messages on the
|
||||
client→server stream. Both are required for invite links to be safe.
|
||||
|
||||
## 2. Tech stack
|
||||
|
||||
- Node 22, CommonJS, Express 4 — matches `stash-ex-webapp`.
|
||||
- Deployed by Gitea Actions to Portainer's Swarm endpoint; single replica, pinned
|
||||
placement, named volume (see README).
|
||||
- `ws` for all WebSocket endpoints; Node's built-in `node:sqlite` for persistence
|
||||
(no native modules, so the container has no build stage).
|
||||
- `@novnc/novnc` vendored and served as browser ES modules.
|
||||
- Vanilla JS frontend in `public/` (no build step).
|
||||
- Auth delegated to **garagedoor-node-ws** (`http://192.168.4.208:8000`) — proxy pattern,
|
||||
never hold the JWT secret locally.
|
||||
|
||||
## 3. Data model (SQLite)
|
||||
|
||||
- `clients` — id, name, mode, host/port, encrypted VNC password, agent key hash,
|
||||
`require_consent`, tags, os, agent_version, last_seen_at, status, created_by.
|
||||
- `grants` — per-user access to a client with a role and optional expiry.
|
||||
- `invites` — hashed token, kind (`enroll` | `session`), target client, role, max_uses,
|
||||
uses, expiry, revocation.
|
||||
- `sessions` — audit trail of every connection: who, what client, role, bytes, duration,
|
||||
source (`web` | `invite`), end reason.
|
||||
- `audit` — admin actions (client created/deleted, invite issued/revoked, grant changed).
|
||||
|
||||
Secrets at rest (VNC passwords, agent keys) are AES-256-GCM encrypted with a key derived
|
||||
from `ENCRYPTION_KEY`. Invite and agent tokens are stored **hashed**, never plaintext.
|
||||
|
||||
## 4. Access control model
|
||||
|
||||
Roles resolved per (user, client) at connect time:
|
||||
|
||||
| Role | Can |
|
||||
|---|---|
|
||||
| `admin` | Everything: CRUD clients, issue/revoke invites, manage grants, view audit. |
|
||||
| `operator` | Connect with full keyboard/mouse control to granted clients. |
|
||||
| `viewer` | Connect **view-only** (input filtered at the proxy) to granted clients. |
|
||||
|
||||
- Admin = garagedoor username in `ADMIN_USERS`, or garagedoor `level` ≥ `ADMIN_LEVEL`.
|
||||
- Non-admins see only clients they hold a grant for.
|
||||
- `require_consent` on a client makes the agent prompt the local user before each session.
|
||||
- Every WS connect uses a **one-time, 30-second ticket** minted by `POST /api/sessions`,
|
||||
so long-lived JWTs never appear in URLs or proxy logs.
|
||||
|
||||
## 5. Invite links — two kinds
|
||||
|
||||
1. **Enrollment invite** `/enroll/<token>` — hand to a machine you want to manage. Page
|
||||
shows the install one-liner with a single-use token baked in; running it registers the
|
||||
machine as a client and it appears in the list.
|
||||
2. **Session invite** `/s/<token>` — hand to a person. Time-limited, use-limited access to
|
||||
**one** client at a fixed role (`viewer` or `operator`), no login required. This is the
|
||||
"send the customer a link" flow.
|
||||
|
||||
Both are revocable, expiring, and logged.
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature checklist
|
||||
|
||||
### Phase 1 — Foundation
|
||||
- [x] Project scaffold, package.json, env config, .gitignore
|
||||
- [x] SQLite schema + migrations on boot
|
||||
- [x] Secret encryption helper (AES-256-GCM)
|
||||
- [x] garagedoor auth proxy + `requireAuth` / `requireAdmin` middleware
|
||||
- [x] Login screen, token in localStorage, 401 → re-login
|
||||
|
||||
### Phase 2 — Clients & persistence
|
||||
- [x] `GET/POST/PATCH/DELETE /api/clients`
|
||||
- [x] Client list UI with status, tags, last-seen
|
||||
- [x] Add/edit client form (direct mode: host/port/password)
|
||||
- [x] Online/offline status tracking for agent clients
|
||||
|
||||
### Phase 3 — VNC bridge
|
||||
- [x] WS↔TCP bridge for direct mode
|
||||
- [x] Server-side RFB handshake + VNC Authentication (password never reaches browser)
|
||||
- [x] View-only enforcement by filtering client→server RFB messages
|
||||
- [x] noVNC viewer page: scaling, fullscreen, clipboard, Ctrl-Alt-Del
|
||||
- [x] One-time session tickets
|
||||
|
||||
### Phase 4 — Agent (NAT traversal)
|
||||
- [x] Agent control channel `/agent` with heartbeat + reconnect
|
||||
- [x] Data tunnel `/tunnel` paired to a waiting browser socket
|
||||
- [x] `agent/agent.js` — zero npm dependencies, uses Node 22's global WebSocket
|
||||
- [x] Enrollment via invite token → agent key issued once
|
||||
- [x] Local consent prompt when `require_consent` is set
|
||||
|
||||
### Phase 5 — Invites & access control
|
||||
- [x] Issue/list/revoke invites (both kinds)
|
||||
- [x] `/enroll/<token>` enrollment page
|
||||
- [x] `/s/<token>` session invite page (no login)
|
||||
- [x] Per-user grants CRUD
|
||||
- [x] Role resolution + enforcement at connect
|
||||
|
||||
### Phase 6 — Audit & operations
|
||||
- [x] Session history table + live "who is connected now"
|
||||
- [x] Audit log of admin actions
|
||||
- [x] Admin can force-disconnect an active session
|
||||
- [x] Dockerfile + docker-compose + Gitea Actions CI/CD to Portainer
|
||||
- [x] README with deploy + client setup instructions
|
||||
- [x] End-to-end test suite (`pnpm test`) — fake VNC server + fake auth service,
|
||||
72 assertions covering auth, RBAC, VNC auth, view-only, invites, agent tunnel
|
||||
|
||||
### Phase 7 — Front end polish
|
||||
- [~] Modern visual design pass across all five pages
|
||||
- [~] Particle background (network-of-machines motif), reduced-motion aware,
|
||||
never rendered behind a live VNC canvas
|
||||
|
||||
### Phase 8 — Later / nice to have
|
||||
- [ ] File transfer between operator and client
|
||||
- [ ] Clipboard sync toggle per session
|
||||
- [ ] Multi-monitor selection
|
||||
- [ ] Session recording (RFB stream capture + replay)
|
||||
- [ ] Chat sidebar during a support session
|
||||
- [ ] Wake-on-LAN integration (sibling `wol-fleet-webapp`)
|
||||
- [ ] TOTP step-up before controlling a flagged client
|
||||
- [ ] Agent auto-update
|
||||
- [ ] RDP backend alongside VNC
|
||||
|
||||
---
|
||||
|
||||
## 7. Security notes
|
||||
|
||||
- The hub is the only thing that knows VNC passwords; browsers get an RFB stream that has
|
||||
already cleared authentication.
|
||||
- View-only is enforced **server-side**, not by hiding UI.
|
||||
- Invite tokens: 32 bytes of `crypto.randomBytes`, stored as SHA-256, compared in constant
|
||||
time, single-use by default.
|
||||
- Deploy behind HTTPS (see the `lan-https-host` setup) — RFB over plain `ws://` is
|
||||
cleartext framebuffer data.
|
||||
- Known upstream debt in garagedoor (SQL injection, hardcoded secret) is *not* inherited:
|
||||
this app never touches that DB or that secret.
|
||||
Reference in New Issue
Block a user