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
+51
View File
@@ -0,0 +1,51 @@
'use strict';
const path = require('path');
function bool(v, dflt) {
if (v === undefined || v === '') return dflt;
return /^(1|true|yes|on)$/i.test(String(v));
}
function list(v) {
return String(v || '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
const config = {
port: Number(process.env.PORT || 8080),
host: process.env.HOST || '0.0.0.0',
// garagedoor-node-ws central auth
authUrl: (process.env.AUTH_URL || 'http://192.168.4.208:8000').replace(/\/$/, ''),
// Admins: username allowlist, or garagedoor `level` at/above this threshold.
adminUsers: list(process.env.ADMIN_USERS),
adminLevel: process.env.ADMIN_LEVEL === '' || process.env.ADMIN_LEVEL === undefined
? null
: Number(process.env.ADMIN_LEVEL),
dbPath: process.env.DB_PATH || path.join(__dirname, '..', 'data', 'rcs.db'),
// Key material for AES-256-GCM at-rest encryption of VNC passwords / agent keys.
encryptionKey: process.env.ENCRYPTION_KEY || '',
// Public base URL, used when rendering invite links. Falls back to the request host.
publicUrl: (process.env.PUBLIC_URL || '').replace(/\/$/, ''),
ticketTtlMs: Number(process.env.TICKET_TTL_MS || 30_000),
inviteDefaultTtlMs: Number(process.env.INVITE_TTL_MS || 24 * 60 * 60 * 1000),
agentOfflineAfterMs: Number(process.env.AGENT_OFFLINE_AFTER_MS || 90_000),
consentTimeoutMs: Number(process.env.CONSENT_TIMEOUT_MS || 45_000),
// Session invites let unauthenticated people connect. Off by default is safer,
// but the whole point of this app is handing a link to someone, so: on.
allowSessionInvites: bool(process.env.ALLOW_SESSION_INVITES, true),
trustProxy: bool(process.env.TRUST_PROXY, true),
logLevel: process.env.LOG_LEVEL || 'info',
};
module.exports = config;