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>
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).
wsfor all WebSocket endpoints; Node's built-innode:sqlitefor persistence (no native modules, so the container has no build stage).@novnc/novncvendored 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 garagedoorlevel≥ADMIN_LEVEL. - Non-admins see only clients they hold a grant for.
require_consenton 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
- 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. - Session invite
/s/<token>— hand to a person. Time-limited, use-limited access to one client at a fixed role (vieweroroperator), 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/requireAdminmiddleware - 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
/agentwith heartbeat + reconnect - Data tunnel
/tunnelpaired 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_consentis 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-hostsetup) — RFB over plainws://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.