Files
rmancinasandClaude Opus 5 88be2bf867
Build and Deploy Remote Control Support / Test (push) Successful in 11s
Build and Deploy Remote Control Support / Build Image (push) Successful in 47s
Build and Deploy Remote Control Support / Deploy to Portainer (push) Successful in 7s
fix(http): 400 on malformed JSON body instead of 500
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>
2026-08-11 23:55:48 -07:00

181 lines
8.9 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
- [x] Modern visual design pass across all five pages
- [x] 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.com` DNS + 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-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.