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>
160 lines
5.4 KiB
JavaScript
160 lines
5.4 KiB
JavaScript
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 = '/'; } },
|
|
]);
|
|
}
|
|
})();
|