Files
remote-control-support-webapp/PLAN.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

171 lines
8.4 KiB
Markdown

# 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.