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
+309
View File
@@ -0,0 +1,309 @@
'use strict';
// Persistence. Uses Node's built-in SQLite so the app has zero native build
// dependencies — important because this ships as a container to unraid.
// Positional (?) parameters only: named-parameter binding differs between
// node:sqlite releases.
const fs = require('fs');
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const config = require('./config');
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
const db = new DatabaseSync(config.dbPath);
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
mode TEXT NOT NULL DEFAULT 'direct', -- 'direct' | 'agent'
host TEXT,
port INTEGER NOT NULL DEFAULT 5900,
vnc_password_enc TEXT,
agent_key_hash TEXT,
require_consent INTEGER NOT NULL DEFAULT 0,
tags TEXT NOT NULL DEFAULT '',
os TEXT,
hostname TEXT,
agent_version TEXT,
last_seen_at INTEGER,
last_ip TEXT,
enrolled_at INTEGER,
created_by TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_clients_name ON clients(name);
CREATE TABLE IF NOT EXISTS grants (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
username TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer', -- 'viewer' | 'operator'
expires_at INTEGER,
created_by TEXT,
created_at INTEGER NOT NULL,
UNIQUE (client_id, username)
);
CREATE INDEX IF NOT EXISTS idx_grants_username ON grants(username);
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL, -- 'enroll' | 'session'
client_id TEXT REFERENCES clients(id) ON DELETE CASCADE,
label TEXT,
role TEXT, -- session invites
prefill TEXT, -- enroll invites, JSON
max_uses INTEGER NOT NULL DEFAULT 1,
uses INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER,
revoked_at INTEGER,
last_used_at INTEGER,
created_by TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_invites_client ON invites(client_id);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
client_name TEXT,
username TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'web', -- 'web' | 'invite'
invite_id TEXT,
role TEXT NOT NULL,
remote_ip TEXT,
started_at INTEGER NOT NULL,
ended_at INTEGER,
bytes_in INTEGER NOT NULL DEFAULT 0,
bytes_out INTEGER NOT NULL DEFAULT 0,
end_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_client ON sessions(client_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
username TEXT,
action TEXT NOT NULL,
target TEXT,
detail TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts DESC);
`);
const now = () => Date.now();
/* ---------------------------------------------------------------- clients */
const clients = {
list() {
return db.prepare('SELECT * FROM clients ORDER BY name COLLATE NOCASE').all();
},
listForUser(username) {
return db
.prepare(
`SELECT c.*, g.role AS granted_role, g.expires_at AS grant_expires_at
FROM clients c
JOIN grants g ON g.client_id = c.id
WHERE g.username = ?
AND (g.expires_at IS NULL OR g.expires_at > ?)
ORDER BY c.name COLLATE NOCASE`
)
.all(username, now());
},
get(id) {
return db.prepare('SELECT * FROM clients WHERE id = ?').get(id);
},
getByName(name) {
return db.prepare('SELECT * FROM clients WHERE name = ? COLLATE NOCASE').get(name);
},
create(c) {
const ts = now();
db.prepare(
`INSERT INTO clients
(id, name, description, mode, host, port, vnc_password_enc, agent_key_hash,
require_consent, tags, os, hostname, agent_version, enrolled_at,
created_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
).run(
c.id, c.name, c.description ?? null, c.mode, c.host ?? null, c.port ?? 5900,
c.vnc_password_enc ?? null, c.agent_key_hash ?? null,
c.require_consent ? 1 : 0, c.tags ?? '', c.os ?? null, c.hostname ?? null,
c.agent_version ?? null, c.enrolled_at ?? null, c.created_by ?? null, ts, ts
);
return clients.get(c.id);
},
update(id, fields) {
const allowed = [
'name', 'description', 'mode', 'host', 'port', 'vnc_password_enc', 'agent_key_hash',
'require_consent', 'tags', 'os', 'hostname', 'agent_version', 'last_seen_at',
'last_ip', 'enrolled_at',
];
const keys = Object.keys(fields).filter((k) => allowed.includes(k));
if (!keys.length) return clients.get(id);
const sql = `UPDATE clients SET ${keys.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`;
db.prepare(sql).run(...keys.map((k) => fields[k]), now(), id);
return clients.get(id);
},
touch(id, ip) {
db.prepare('UPDATE clients SET last_seen_at = ?, last_ip = ? WHERE id = ?').run(now(), ip ?? null, id);
},
remove(id) {
db.prepare('DELETE FROM clients WHERE id = ?').run(id);
},
};
/* ----------------------------------------------------------------- grants */
const grants = {
listForClient(clientId) {
return db.prepare('SELECT * FROM grants WHERE client_id = ? ORDER BY username').all(clientId);
},
listForUser(username) {
return db.prepare('SELECT * FROM grants WHERE username = ?').all(username);
},
find(clientId, username) {
return db
.prepare('SELECT * FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
.get(clientId, username);
},
upsert(g) {
db.prepare(
`INSERT INTO grants (id, client_id, username, role, expires_at, created_by, created_at)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(client_id, username)
DO UPDATE SET role = excluded.role, expires_at = excluded.expires_at`
).run(g.id, g.client_id, g.username.toLowerCase(), g.role, g.expires_at ?? null, g.created_by ?? null, now());
return grants.find(g.client_id, g.username);
},
remove(clientId, username) {
db.prepare('DELETE FROM grants WHERE client_id = ? AND username = ? COLLATE NOCASE')
.run(clientId, username);
},
};
/* ---------------------------------------------------------------- invites */
const invites = {
list() {
return db
.prepare(
`SELECT i.*, c.name AS client_name
FROM invites i LEFT JOIN clients c ON c.id = i.client_id
ORDER BY i.created_at DESC`
)
.all();
},
get(id) {
return db.prepare('SELECT * FROM invites WHERE id = ?').get(id);
},
findByHash(hash) {
return db.prepare('SELECT * FROM invites WHERE token_hash = ?').get(hash);
},
create(i) {
db.prepare(
`INSERT INTO invites
(id, token_hash, kind, client_id, label, role, prefill, max_uses, uses, expires_at, created_by, created_at)
VALUES (?,?,?,?,?,?,?,?,0,?,?,?)`
).run(
i.id, i.token_hash, i.kind, i.client_id ?? null, i.label ?? null, i.role ?? null,
i.prefill ?? null, i.max_uses ?? 1, i.expires_at ?? null, i.created_by ?? null, now()
);
return invites.get(i.id);
},
consume(id) {
db.prepare('UPDATE invites SET uses = uses + 1, last_used_at = ? WHERE id = ?').run(now(), id);
},
revoke(id) {
db.prepare('UPDATE invites SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL').run(now(), id);
},
remove(id) {
db.prepare('DELETE FROM invites WHERE id = ?').run(id);
},
};
// An invite is usable when it is not revoked, not expired, and has uses left.
function inviteUsable(inv) {
if (!inv) return { ok: false, reason: 'not found' };
if (inv.revoked_at) return { ok: false, reason: 'revoked' };
if (inv.expires_at && inv.expires_at < now()) return { ok: false, reason: 'expired' };
if (inv.max_uses > 0 && inv.uses >= inv.max_uses) return { ok: false, reason: 'already used' };
return { ok: true };
}
/* --------------------------------------------------------------- sessions */
const sessions = {
start(s) {
db.prepare(
`INSERT INTO sessions
(id, client_id, client_name, username, source, invite_id, role, remote_ip, started_at)
VALUES (?,?,?,?,?,?,?,?,?)`
).run(
s.id, s.client_id, s.client_name ?? null, s.username, s.source ?? 'web',
s.invite_id ?? null, s.role, s.remote_ip ?? null, now()
);
},
end(id, { bytesIn = 0, bytesOut = 0, reason = 'closed' } = {}) {
db.prepare(
`UPDATE sessions SET ended_at = ?, bytes_in = ?, bytes_out = ?, end_reason = ?
WHERE id = ? AND ended_at IS NULL`
).run(now(), bytesIn, bytesOut, reason, id);
},
// Anything still marked open at boot was killed by a restart, not by a user.
closeOrphans() {
const r = db
.prepare(`UPDATE sessions SET ended_at = ?, end_reason = 'server restart' WHERE ended_at IS NULL`)
.run(now());
return r.changes;
},
recent(limit = 100, clientId = null) {
return clientId
? db.prepare('SELECT * FROM sessions WHERE client_id = ? ORDER BY started_at DESC LIMIT ?').all(clientId, limit)
: db.prepare('SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?').all(limit);
},
};
/* ------------------------------------------------------------------ audit */
function audit(username, action, target, detail) {
db.prepare('INSERT INTO audit (ts, username, action, target, detail) VALUES (?,?,?,?,?)').run(
now(), username ?? null, action, target ?? null,
detail === undefined || detail === null ? null : typeof detail === 'string' ? detail : JSON.stringify(detail)
);
}
audit.recent = (limit = 200) =>
db.prepare('SELECT * FROM audit ORDER BY ts DESC LIMIT ?').all(limit);
module.exports = { db, clients, grants, invites, inviteUsable, sessions, audit };