rmancinasandClaude Opus 5 8af9d39ffd
Build and Deploy Remote Control Support / Test (push) Successful in 18s
Build and Deploy Remote Control Support / Build Image (push) Failing after 20s
Build and Deploy Remote Control Support / Deploy to Portainer (push) Skipped
feat(ui): warm-graphite design system and particle background
Reworks the console's look away from generic dark-mode defaults: the
neutral ramp carries a faint violet cast, and the signal palette now
distinguishes connection topology by colour — mint for live agents,
iris for direct connections, copper for consent-required machines.

Type contrast comes from pairing the platform mono against the platform
sans rather than a webfont, since the box this runs on has no internet.

particles.js draws a proximity-linked node field: full strength on the
login and public pages, frozen static in the console, and entirely
absent from the viewer, where every frame belongs to the remote desktop.
It caps DPR at 2, scales node count to viewport area, throttles to 30fps,
cancels rAF when the tab is hidden, and honours prefers-reduced-motion.

app.js and viewer.js are unchanged; the particle layer mounts itself and
tracks the console via MutationObserver on #app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:40:16 -07:00

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 for the architecture and feature checklist.


Quick start

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):

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 32set 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:

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:

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 <link|token> [--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 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.

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
S
Description
No description provided
Readme
214 KiB
Languages
JavaScript 68.2%
HTML 14.8%
CSS 12.3%
Shell 2.4%
PowerShell 1.8%
Other 0.5%