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>
181 lines
8.9 KiB
Markdown
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.
|