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
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Secrets at rest: VNC passwords and agent keys are AES-256-GCM encrypted.
// Tokens that we only ever need to *compare* (invite tokens, agent keys as
// presented by a client) are stored as SHA-256 and checked in constant time.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const config = require('./config');
const KEY_FILE = path.join(path.dirname(config.dbPath), 'encryption.key');
let keyCache = null;
// A stable 32-byte key. Prefer ENCRYPTION_KEY from the environment; otherwise
// generate one next to the database so a bare `npm start` still works and stays
// decryptable across restarts.
function key() {
if (keyCache) return keyCache;
let material = config.encryptionKey;
if (!material) {
fs.mkdirSync(path.dirname(KEY_FILE), { recursive: true });
if (fs.existsSync(KEY_FILE)) {
material = fs.readFileSync(KEY_FILE, 'utf8').trim();
} else {
material = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(KEY_FILE, material, { mode: 0o600 });
console.warn(`[crypto] ENCRYPTION_KEY not set — generated one at ${KEY_FILE}. Back it up.`);
}
}
keyCache = crypto.createHash('sha256').update(material, 'utf8').digest();
return keyCache;
}
function encrypt(plaintext) {
if (plaintext === null || plaintext === undefined || plaintext === '') return null;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `v1.${iv.toString('base64')}.${tag.toString('base64')}.${ct.toString('base64')}`;
}
function decrypt(blob) {
if (!blob) return null;
const parts = String(blob).split('.');
if (parts.length !== 4 || parts[0] !== 'v1') return null;
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(parts[1], 'base64'));
decipher.setAuthTag(Buffer.from(parts[2], 'base64'));
return Buffer.concat([decipher.update(Buffer.from(parts[3], 'base64')), decipher.final()]).toString('utf8');
} catch {
// Wrong key or tampered ciphertext — treat as absent rather than crashing a session.
return null;
}
}
// URL-safe random token, 32 bytes of entropy.
function randomToken(bytes = 32) {
return crypto.randomBytes(bytes).toString('base64url');
}
function sha256(value) {
return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
}
function timingSafeEqualHex(a, b) {
const ba = Buffer.from(String(a || ''), 'hex');
const bb = Buffer.from(String(b || ''), 'hex');
if (ba.length !== bb.length || ba.length === 0) return false;
return crypto.timingSafeEqual(ba, bb);
}
function uuid() {
return crypto.randomUUID();
}
module.exports = { encrypt, decrypt, randomToken, sha256, timingSafeEqualHex, uuid };