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
+159
View File
@@ -0,0 +1,159 @@
import RFB from '/novnc/core/rfb.js';
// The viewer takes either a client id (mint a ticket via the API) or a ticket
// that has already been minted for it — the support-link page does the latter,
// since those visitors have no login at all.
const params = new URLSearchParams(location.search);
const token = localStorage.getItem('rcs.token');
const el = (id) => document.getElementById(id);
const overlay = el('status-overlay');
let rfb = null;
let scaled = true;
function status(title, text, actions = []) {
el('status-title').textContent = title;
el('status-text').textContent = text || '';
const box = el('status-actions');
box.innerHTML = '';
for (const a of actions) {
const b = document.createElement('button');
b.textContent = a.label;
b.className = a.primary ? 'primary' : '';
b.addEventListener('click', a.onClick);
box.append(b);
}
overlay.classList.remove('hidden');
}
function hideStatus() {
overlay.classList.add('hidden');
}
function setControls(enabled, { control = true } = {}) {
for (const id of ['btn-fit', 'btn-fullscreen', 'btn-disconnect']) el(id).disabled = !enabled;
for (const id of ['btn-cad', 'btn-clipboard']) el(id).disabled = !enabled || !control;
}
async function mintTicket() {
const clientId = params.get('client');
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ clientId, viewOnly: params.get('viewOnly') === '1' }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `could not start a session (${res.status})`);
return data;
}
function connect(session) {
el('client-name').textContent = session.clientName || 'Remote machine';
document.title = `${session.clientName || 'Remote'} — session`;
const viewOnly = session.role === 'viewer';
const roleTag = el('role-tag');
roleTag.textContent = viewOnly ? 'view only' : 'full control';
roleTag.className = viewOnly ? 'tag' : 'tag role';
roleTag.hidden = false;
if (session.requireConsent) {
status('Waiting for permission', 'Someone at that machine has to allow the connection.');
} else {
status('Connecting…', 'Negotiating with the remote desktop.');
}
const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws/vnc?ticket=${encodeURIComponent(session.ticket)}`;
rfb = new RFB(el('screen'), url);
rfb.viewOnly = viewOnly; // the hub enforces this too; this just avoids noise
rfb.scaleViewport = true;
rfb.resizeSession = false;
rfb.background = '#05070a';
rfb.clipViewport = false;
rfb.focusOnClick = true;
rfb.addEventListener('connect', () => {
hideStatus();
el('conn-dot').className = 'dot online';
el('conn-status').textContent = 'connected';
setControls(true, { control: !viewOnly });
});
rfb.addEventListener('disconnect', (e) => {
el('conn-dot').className = 'dot offline';
el('conn-status').textContent = 'disconnected';
setControls(false);
const reason = e.detail?.reason || (e.detail?.clean ? 'The session ended.' : 'The connection dropped.');
status(e.detail?.clean ? 'Session ended' : 'Disconnected', reason, [
{ label: 'Reconnect', primary: true, onClick: () => location.reload() },
{ label: 'Close', onClick: () => window.close() },
]);
});
rfb.addEventListener('credentialsrequired', () => {
// The hub authenticates upstream, so the browser should never be asked.
status('Unexpected password prompt', 'The hub could not complete VNC authentication for this machine.');
rfb.disconnect();
});
rfb.addEventListener('securityfailure', (e) => {
status('Rejected', e.detail?.reason || 'The remote machine refused the connection.');
});
}
/* ------------------------------------------------------------- toolbar */
el('btn-cad').addEventListener('click', () => rfb?.sendCtrlAltDel());
el('btn-clipboard').addEventListener('click', async () => {
try {
const text = await navigator.clipboard.readText();
if (text) rfb?.clipboardPasteFrom(text);
} catch {
const text = prompt('Text to send to the remote machine:');
if (text) rfb?.clipboardPasteFrom(text);
}
});
el('btn-fit').addEventListener('click', (e) => {
scaled = !scaled;
if (rfb) rfb.scaleViewport = scaled;
e.currentTarget.textContent = scaled ? 'Fit' : '1:1';
});
el('btn-fullscreen').addEventListener('click', () => {
if (document.fullscreenElement) document.exitFullscreen();
else document.documentElement.requestFullscreen();
});
el('btn-disconnect').addEventListener('click', () => rfb?.disconnect());
/* ---------------------------------------------------------------- boot */
(async function start() {
try {
if (params.get('ticket')) {
connect({
ticket: params.get('ticket'),
clientName: params.get('name') || 'Remote machine',
role: params.get('role') || 'viewer',
requireConsent: params.get('consent') === '1',
});
// The ticket is single-use; drop it from the address bar and history.
history.replaceState(null, '', location.pathname);
return;
}
if (!params.get('client')) throw new Error('nothing to connect to');
if (!token) throw new Error('you are not signed in');
connect(await mintTicket());
} catch (err) {
status('Cannot start', err.message, [
{ label: 'Back to machines', primary: true, onClick: () => { location.href = '/'; } },
]);
}
})();