express.json() raising a parse error was falling through to the generic 500 handler, which reads as a server fault for what is a bad request. Also maps the body-size limit to 413. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.9 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
Deployed
Live at http://192.168.4.212:5910 (Swarm ingress). Published port is 5910, not
8091 — 8091 is reserved on the ingress by ai-training-lab_ai-lab, and that
reservation holds even while nothing answers on it, so probing the port cannot
tell you it is taken. Only the Swarm's own view is authoritative.
Remaining to be usable from outside the LAN console:
support.freakma.comDNS + Nginx Proxy Manager host, WebSocket support on- First machine enrolled
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.