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

8.4 KiB

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

  • Project scaffold, package.json, env config, .gitignore
  • SQLite schema + migrations on boot
  • Secret encryption helper (AES-256-GCM)
  • garagedoor auth proxy + requireAuth / requireAdmin middleware
  • Login screen, token in localStorage, 401 → re-login

Phase 2 — Clients & persistence

  • GET/POST/PATCH/DELETE /api/clients
  • Client list UI with status, tags, last-seen
  • Add/edit client form (direct mode: host/port/password)
  • Online/offline status tracking for agent clients

Phase 3 — VNC bridge

  • WS↔TCP bridge for direct mode
  • Server-side RFB handshake + VNC Authentication (password never reaches browser)
  • View-only enforcement by filtering client→server RFB messages
  • noVNC viewer page: scaling, fullscreen, clipboard, Ctrl-Alt-Del
  • One-time session tickets

Phase 4 — Agent (NAT traversal)

  • Agent control channel /agent with heartbeat + reconnect
  • Data tunnel /tunnel paired to a waiting browser socket
  • agent/agent.js — zero npm dependencies, uses Node 22's global WebSocket
  • Enrollment via invite token → agent key issued once
  • Local consent prompt when require_consent is set

Phase 5 — Invites & access control

  • Issue/list/revoke invites (both kinds)
  • /enroll/<token> enrollment page
  • /s/<token> session invite page (no login)
  • Per-user grants CRUD
  • Role resolution + enforcement at connect

Phase 6 — Audit & operations

  • Session history table + live "who is connected now"
  • Audit log of admin actions
  • Admin can force-disconnect an active session
  • Dockerfile + docker-compose + Gitea Actions CI/CD to Portainer
  • README with deploy + client setup instructions
  • 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.