# Remote Control Support Self-hosted remote support over VNC. A TeamViewer-shaped console that runs in the browser: pick a machine, get its screen, take over the mouse and keyboard. - **noVNC in the browser** — nothing to install for the person giving support. - **Two ways to reach a machine** — connect straight to a VNC server on the LAN, or have the machine run a small agent that dials *out*, so NAT and firewalls stop mattering. - **Invite links** — one kind adds a machine, the other hands someone time-limited access to a single machine with no account at all. - **Access control that is actually enforced** — view-only is applied at the proxy by dropping input messages, not by hiding buttons. Stored VNC passwords never reach the browser. - **Persisted** — machines, grants, invites, session history and an audit log live in SQLite. - Login delegated to the existing **garagedoor-node-ws** auth service. See [PLAN.md](PLAN.md) for the architecture and feature checklist. --- ## Quick start ```bash pnpm install cp .env.example .env # set ADMIN_USERS at minimum pnpm start # http://localhost:8080 ``` Run the test suite (boots the real server against a fake VNC server and a fake auth service, then drives it as a browser would): ```bash pnpm test ``` ## Deploy CI/CD is Gitea Actions → registry → Portainer, same shape as the other services here. Pushing to `main` runs the test suite, builds `git.mancinas.io/rmancinas/remote-control-support-webapp:latest`, and deploys the stack (`.gitea/workflows/deploy.yml`). Gitea repo secrets required: | Secret | Value | |---|---| | `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` | git.mancinas.io login | | `PORTAINER_URL` | `https://192.168.4.212:9443` | | `PORTAINER_API_KEY` | Portainer → user icon → Access tokens | | `PORTAINER_ENDPOINT_ID` | `2` (the local Swarm endpoint) | | `PORTAINER_STACK_NAME` | e.g. `remote-control-support` | | `AUTH_URL` | `http://192.168.4.208:8000` | | `ADMIN_USERS` | e.g. `rmancinas` | | `ENCRYPTION_KEY` | `openssl rand -hex 32` — **set this**, see below | | `PUBLIC_URL` | e.g. `https://remote.mancinas.dev` | Two things the compose file must keep, both because state lives in the process and on one node: - **`replicas: 1`.** Agent control sockets and live sessions are held in memory. A second replica would not see the first one's agents, and connects would fail at random depending on which task the browser landed on. - **`node.role == manager` placement + named volume.** The SQLite volume is node-local. If the task reschedules elsewhere it comes up with an empty database. `ENCRYPTION_KEY` is worth setting explicitly rather than letting the container generate one: the generated key lives in the same volume as the database, so losing the volume loses both, and the stored VNC passwords with them. Locally: ```bash docker build -t rcs . && docker run --rm -p 8080:8080 -v rcs_data:/data rcs ``` Put it behind HTTPS before using it for real: RFB is a raw framebuffer stream and `ws://` sends it in the clear. The `lan-https-host` setup covers this — and Nginx Proxy Manager in front is also what supplies the real client IP, since the app runs with `TRUST_PROXY=true`. **Enable WebSocket support on the proxy host** or nothing connects. --- ## Adding a machine ### Direct (the hub can reach its VNC port) **Add machine** → host, port, VNC password. Good for servers and desktops on the same LAN as the hub. ### Agent (the machine dials out) **Invite a machine** produces a link. Open it on the target machine and it shows a one-liner: ```bash curl -fsSL https://your-hub/download/agent.js -o rcs-agent.js \ && node rcs-agent.js enroll https://your-hub/enroll/TOKEN \ && node rcs-agent.js run ``` The agent needs Node 22+ and a VNC server listening on `127.0.0.1:5900`: | OS | VNC server | |---|---| | macOS | System Settings → General → Sharing → Screen Sharing | | Windows | TightVNC or UltraVNC | | Linux | `x11vnc -localhost -rfbport 5900` | Enrolment issues an agent key, stored hashed on the hub and written to `~/.rcs-agent.json` (mode 600) on the machine. To keep it running, wrap `node rcs-agent.js run` in a systemd unit, a launchd plist, or a scheduled task. Agent commands: ``` node agent.js enroll [--hub URL] [--vnc-host H] [--vnc-port N] [--name NAME] node agent.js run [--vnc-host H] [--vnc-port N] node agent.js status ``` --- ## Access control | Role | Gets | |---|---| | admin | Everything: machines, invites, grants, audit, force-disconnect | | operator | Full keyboard and mouse on machines they were granted | | viewer | Screen only — input is dropped by the proxy | Admins are set by `ADMIN_USERS` (comma-separated usernames) or `ADMIN_LEVEL` (garagedoor `level` threshold). **With neither set, every authenticated user is an admin** — fine for a single operator, wrong for a shared install. Everyone else sees only machines they hold a grant for (**⋯ → Who has access**). Turning on *ask first* for a machine makes its agent prompt whoever is sitting there before each session, and the session does not start until they accept. ### Support links **Support link** creates a URL that grants one machine, one role, until it expires. The person opening it types a name and connects — no account. Revoke it from the Invites tab at any time; live sessions can be cut from the Sessions tab. --- ## How a session actually works ``` browser ──wss /ws/vnc?ticket=…──► hub ──tcp──► VNC server (direct) browser ──wss /ws/vnc?ticket=…──► hub ◄─wss /ws/tunnel── agent ──tcp──► VNC server (agent) ``` The hub is a deliberate man-in-the-middle. It completes the RFB handshake with the real VNC server itself — including VNC Authentication, using the password it holds encrypted — and then presents the browser a handshake that needs no password. Because it sits in the middle of the message stream it can also parse the browser→server direction and drop `KeyEvent`, `PointerEvent`, `ClientCutText`, `SetDesktopSize` and `xvp` for view-only sessions. WebSockets cannot carry an `Authorization` header, so `POST /api/sessions` mints a **single-use ticket that expires in 30 seconds** and the socket carries only that. Node's OpenSSL 3 build dropped `des-ecb` from the default provider, so `server/vnc/des.js` carries a small DES implementation purely to answer the VNC auth challenge. It is verified against the standard test vectors in the test suite. --- ## Configuration Everything is environment variables — see [.env.example](.env.example). | Variable | Default | Notes | |---|---|---| | `PORT` / `HOST` | `8080` / `0.0.0.0` | | | `AUTH_URL` | `http://192.168.4.208:8000` | garagedoor-node-ws | | `ADMIN_USERS` | *(empty)* | Comma-separated. Empty + no `ADMIN_LEVEL` = everyone is admin | | `ADMIN_LEVEL` | *(unset)* | garagedoor `level` at or above this is admin | | `DB_PATH` | `./data/rcs.db` | | | `ENCRYPTION_KEY` | *(generated)* | Encrypts stored VNC passwords. Back it up | | `PUBLIC_URL` | *(request host)* | Base URL used when rendering invite links | | `ALLOW_SESSION_INVITES` | `true` | `false` disables no-login support links | | `TICKET_TTL_MS` | `30000` | | | `INVITE_TTL_MS` | `86400000` | Default invite lifetime | | `CONSENT_TIMEOUT_MS` | `45000` | How long to wait for someone to accept | ## API sketch | Method | Path | | |---|---|---| | `POST` | `/api/login` | → token | | `GET` | `/api/clients` | machines you can see | | `POST` | `/api/clients` | admin | | `POST` | `/api/clients/:id/grants` | admin | | `POST` | `/api/clients/:id/agent-key` | admin, returns the key once | | `POST` | `/api/invites` | admin, `kind: enroll \| session` | | `POST` | `/api/sessions` | mint a connect ticket | | `GET` | `/api/sessions/live` | who is connected now | | `POST` | `/api/sessions/:id/kill` | admin | | `GET` | `/api/sessions/history` | | | `GET` | `/api/sessions/audit` | admin | | `POST` | `/api/public/enroll` | no auth, enrolment token | | `POST` | `/api/public/session/:token` | no auth, support link | | WS | `/ws/vnc?ticket=` | browser session | | WS | `/ws/agent?clientId=&key=` | agent control channel | | WS | `/ws/tunnel?clientId=&key=&tunnelId=` | agent data tunnel |