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>
116 lines
4.2 KiB
HTML
116 lines
4.2 KiB
HTML
<!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>Set up remote support</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">
|
|
|
|
<div class="card-header">
|
|
<div class="brand-mark">RC</div>
|
|
<div>
|
|
<strong style="font-size:14px">Remote Support</strong>
|
|
<span class="brand-sub">Machine enrollment</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card-body">
|
|
<span class="card-kicker">One-time setup</span>
|
|
<h1>Set up remote support</h1>
|
|
|
|
<div id="state-loading" class="faint">Checking this link…</div>
|
|
|
|
<div id="state-invalid" class="notice error hidden"></div>
|
|
|
|
<div id="state-ok" class="hidden">
|
|
<p class="faint">
|
|
This registers this computer so it can be supported remotely. It stays connected
|
|
until you stop the agent.
|
|
</p>
|
|
|
|
<ol class="steps">
|
|
<li>
|
|
<strong>Make sure a VNC server is running</strong> on this machine, listening on
|
|
<span class="mono">127.0.0.1:5900</span>.
|
|
<div class="faint" style="margin-top:6px">
|
|
macOS: System Settings → General → Sharing → Screen Sharing.<br>
|
|
Windows: install TightVNC or UltraVNC.<br>
|
|
Linux: <span class="mono">x11vnc -localhost -rfbport 5900</span>.
|
|
</div>
|
|
</li>
|
|
<li>
|
|
<strong>Install <a href="https://nodejs.org" target="_blank" rel="noopener">Node.js 22+</a></strong> if it is not already there.
|
|
</li>
|
|
<li>
|
|
<strong>Run this</strong> in a terminal:
|
|
<pre class="code" id="oneliner"></pre>
|
|
<div class="row wrap" style="margin-top:10px">
|
|
<button class="small" id="copy-unix">Copy for macOS / Linux</button>
|
|
<button class="small" id="copy-win">Copy for Windows</button>
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
|
|
<p class="login-foot">
|
|
Link expires <span id="expiry"></span> · once enrolled, this machine appears in the operator console
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const token = location.pathname.split('/').filter(Boolean).pop();
|
|
const origin = location.origin;
|
|
const link = `${origin}/enroll/${token}`;
|
|
|
|
const unix = `curl -fsSL ${origin}/download/agent.js -o rcs-agent.js \\\n && node rcs-agent.js enroll ${link} \\\n && node rcs-agent.js run`;
|
|
const win = `iwr ${origin}/download/agent.js -OutFile rcs-agent.js; `
|
|
+ `node rcs-agent.js enroll ${link}; node rcs-agent.js run`;
|
|
|
|
function copy(text, button) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
const old = button.textContent;
|
|
button.textContent = 'Copied';
|
|
setTimeout(() => { button.textContent = old; }, 1500);
|
|
});
|
|
}
|
|
|
|
document.getElementById('oneliner').textContent = unix;
|
|
document.getElementById('copy-unix').onclick = (e) => copy(unix, e.currentTarget);
|
|
document.getElementById('copy-win').onclick = (e) => copy(win, e.currentTarget);
|
|
|
|
fetch(`/api/public/invite/${encodeURIComponent(token)}`)
|
|
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
|
|
.then(({ ok, body }) => {
|
|
document.getElementById('state-loading').classList.add('hidden');
|
|
if (!ok || !body.usable || body.kind !== 'enroll') {
|
|
const box = document.getElementById('state-invalid');
|
|
box.textContent = body.error || `This enrollment link is ${body.reason || 'not valid'}.`;
|
|
box.classList.remove('hidden');
|
|
return;
|
|
}
|
|
const expiry = document.getElementById('expiry');
|
|
expiry.textContent = body.expiresAt ? new Date(body.expiresAt).toLocaleString() : 'never';
|
|
document.getElementById('state-ok').classList.remove('hidden');
|
|
})
|
|
.catch(() => {
|
|
document.getElementById('state-loading').textContent = 'Could not reach the server.';
|
|
});
|
|
</script>
|
|
|
|
<script type="module">
|
|
import { mountParticles } from '/particles.js';
|
|
mountParticles(document.getElementById('bg-field'));
|
|
</script>
|
|
</body>
|
|
</html>
|