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
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Join remote session</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="bg-field" id="bg-field" aria-hidden="true"></div>
<div class="center-shell">
<div class="center-card" style="max-width:460px">
<div class="card-header">
<div class="brand-mark">RC</div>
<div>
<strong style="font-size:14px">Remote Support</strong>
<span class="brand-sub">Guest access</span>
</div>
</div>
<div class="card-body">
<span class="card-kicker">Support link</span>
<h1>Join a remote session</h1>
<div id="loading" class="faint">Checking this link…</div>
<div id="invalid" class="notice error hidden"></div>
<form id="join" class="hidden">
<p class="faint">
You have been given <strong id="role-text"></strong> access to
<strong id="client-name"></strong>.
</p>
<div class="field">
<label for="name">Your name</label>
<input id="name" placeholder="so the session log knows who connected" autofocus>
</div>
<button class="primary" type="submit" style="width:100%;padding:11px 14px;font-size:14px" id="join-button">Connect</button>
<p class="login-foot">Access expires <span id="expiry"></span></p>
</form>
</div>
</div>
</div>
<script>
const token = location.pathname.split('/').filter(Boolean).pop();
const el = (id) => document.getElementById(id);
fetch(`/api/public/invite/${encodeURIComponent(token)}`)
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
.then(({ ok, body }) => {
el('loading').classList.add('hidden');
if (!ok || !body.usable || body.kind !== 'session') {
el('invalid').textContent = body.error || `This link is ${body.reason || 'not valid'}.`;
el('invalid').classList.remove('hidden');
return;
}
el('role-text').textContent = body.role === 'operator' ? 'full control' : 'view only';
el('client-name').textContent = body.clientName || 'a machine';
el('expiry').textContent = body.expiresAt ? new Date(body.expiresAt).toLocaleString() : 'never';
el('join').classList.remove('hidden');
})
.catch(() => { el('loading').textContent = 'Could not reach the server.'; });
el('join').addEventListener('submit', async (event) => {
event.preventDefault();
const button = el('join-button');
button.disabled = true;
button.textContent = 'Connecting…';
try {
const res = await fetch(`/api/public/session/${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: el('name').value }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'could not start the session');
// Tickets are single-use and expire in seconds, so handing one to the
// viewer in the URL is fine — the viewer strips it immediately.
const url = new URL('/viewer', location.origin);
url.searchParams.set('ticket', data.ticket);
url.searchParams.set('name', data.clientName);
url.searchParams.set('role', data.role);
location.href = url.toString();
} catch (err) {
el('invalid').textContent = err.message;
el('invalid').classList.remove('hidden');
button.disabled = false;
button.textContent = 'Connect';
}
});
</script>
<script type="module">
import { mountParticles } from '/particles.js';
mountParticles(document.getElementById('bg-field'));
</script>
</body>
</html>