feat: self-hosted remote support over VNC

Browser-based remote control (noVNC) with invite links, per-user access
control, garagedoor SSO and a persisted client list.

The hub proxies RFB rather than pointing the browser at a VNC server. That
is what lets it authenticate upstream with a stored password the browser
never sees, and enforce view-only by dropping input messages on the
client->server stream instead of hiding buttons.

Machines are reachable two ways: direct TCP for LAN hosts, or an outbound
agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the
agent dependency-free, and node:sqlite keeps the image free of native
builds.

Ships with an end-to-end suite that boots the real server against a fake
VNC server and a fake auth service (72 assertions), plus Gitea Actions
CI/CD to Portainer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 23:37:35 -07:00
co-authored by Claude Opus 5
commit 999717f77b
34 changed files with 7057 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# Deployed to Portainer (Swarm endpoint) by .gitea/workflows/deploy.yml.
# ${IMAGE} and the secrets below are injected as env_data at deploy time.
#
# Two constraints this stack cannot break:
# 1. exactly one replica — agent control sockets and live sessions are held in
# process memory, so a second replica would not see the first one's agents;
# 2. pinned placement — the SQLite volume is node-local, so the task has to
# come back to the same node or it wakes up with an empty database.
services:
remote-control-support:
image: ${IMAGE:-git.mancinas.io/rmancinas/remote-control-support-webapp:latest}
ports:
- "8091:8080"
environment:
DB_PATH: /data/rcs.db
TRUST_PROXY: "true"
AUTH_URL: ${AUTH_URL:-http://192.168.4.208:8000}
ADMIN_USERS: ${ADMIN_USERS:-}
ADMIN_LEVEL: ${ADMIN_LEVEL:-}
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
PUBLIC_URL: ${PUBLIC_URL:-}
ALLOW_SESSION_INVITES: ${ALLOW_SESSION_INVITES:-true}
volumes:
- rcs_data:/data
deploy:
replicas: 1
placement:
constraints:
- node.role == manager
update_config:
# start-first would briefly run two containers against one SQLite file.
order: stop-first
failure_action: rollback
restart_policy:
condition: any
delay: 10s
volumes:
rcs_data: