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:
@@ -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>
|
||||
Reference in New Issue
Block a user