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,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 = '/'; } },
|
||||
]);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user