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
+718
View File
@@ -0,0 +1,718 @@
'use strict';
/* Remote Support console. No build step: plain DOM, plain fetch. */
const TOKEN_KEY = 'rcs.token';
const state = {
token: localStorage.getItem(TOKEN_KEY) || null,
username: null,
isAdmin: false,
clients: [],
tab: 'machines',
};
/* ------------------------------------------------------------- helpers */
function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (v === null || v === undefined || v === false) continue;
if (k === 'class') el.className = v;
else if (k === 'text') el.textContent = v;
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
else el.setAttribute(k, v === true ? '' : v);
}
for (const child of children.flat()) {
if (child === null || child === undefined || child === false) continue;
el.append(child.nodeType ? child : document.createTextNode(String(child)));
}
return el;
}
function $(sel) { return document.querySelector(sel); }
function toast(message, ms = 2600) {
document.getElementById('toast')?.remove();
const el = h('div', { id: 'toast', text: message });
document.body.append(el);
setTimeout(() => el.remove(), ms);
}
function ago(ts) {
if (!ts) return 'never';
const s = Math.floor((Date.now() - ts) / 1000);
if (s < 45) return 'just now';
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
function until(ts) {
if (!ts) return 'never';
const s = Math.floor((ts - Date.now()) / 1000);
if (s <= 0) return 'expired';
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
function bytes(n) {
if (!n) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${units[i]}`;
}
function duration(from, to) {
const s = Math.floor(((to || Date.now()) - from) / 1000);
const m = Math.floor(s / 60);
return m ? `${m}m ${s % 60}s` : `${s}s`;
}
/* ----------------------------------------------------------------- api */
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(`/api${path}`, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(state.token ? { Authorization: `Bearer ${state.token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
// The garagedoor token is only good for an hour; expiry means log in again.
signOut();
throw new Error('your session expired — sign in again');
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `request failed (${res.status})`);
return data;
}
/* --------------------------------------------------------------- modal */
function modal({ title, body, actions }) {
const root = $('#modal-root');
const close = () => { root.innerHTML = ''; document.removeEventListener('keydown', onKey); };
const onKey = (e) => { if (e.key === 'Escape') close(); };
document.addEventListener('keydown', onKey);
const box = h('div', { class: 'modal' },
h('div', { class: 'modal-head' },
h('h2', { text: title }),
h('div', { class: 'spacer' }),
h('button', { class: 'ghost small', text: '✕', onclick: close })),
body,
actions ? h('div', { class: 'modal-actions' }, ...actions(close)) : null);
const backdrop = h('div', {
class: 'modal-backdrop',
onclick: (e) => { if (e.target === backdrop) close(); },
}, box);
root.append(backdrop);
box.querySelector('input, select, textarea')?.focus();
return close;
}
function showLink(title, url, note) {
const input = h('input', { value: url, readonly: true });
modal({
title,
body: h('div', {},
note ? h('p', { class: 'faint', text: note }) : null,
h('div', { class: 'copybox' },
input,
h('button', {
class: 'primary',
text: 'Copy',
onclick: async () => {
try {
await navigator.clipboard.writeText(url);
toast('Link copied');
} catch {
input.select();
toast('Press ⌘C / Ctrl-C to copy');
}
},
})),
h('p', { class: 'faint', style: 'margin-bottom:0', text: 'This link is shown once. Copy it now.' })),
actions: (close) => [h('button', { text: 'Done', onclick: close })],
});
input.select();
}
/* ----------------------------------------------------------------- auth */
async function signIn(event) {
event.preventDefault();
const button = $('#login-button');
const err = $('#login-error');
err.classList.add('hidden');
button.disabled = true;
button.textContent = 'Signing in…';
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: $('#username').value, password: $('#password').value }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'sign in failed');
state.token = data.token;
state.username = data.username;
state.isAdmin = data.isAdmin;
localStorage.setItem(TOKEN_KEY, data.token);
$('#password').value = '';
showApp();
} catch (e) {
err.textContent = e.message;
err.classList.remove('hidden');
} finally {
button.disabled = false;
button.textContent = 'Sign in';
}
}
function signOut() {
state.token = null;
localStorage.removeItem(TOKEN_KEY);
$('#app').classList.add('hidden');
$('#login').classList.remove('hidden');
}
/* ------------------------------------------------------------- machines */
function connect(client, viewOnly) {
const url = new URL('/viewer', location.origin);
url.searchParams.set('client', client.id);
if (viewOnly) url.searchParams.set('viewOnly', '1');
window.open(url.toString(), '_blank', 'noopener');
}
function clientCard(client) {
const isAgent = client.mode === 'agent';
const online = isAgent ? client.online : null;
const status = isAgent
? h('span', { class: `dot ${online ? 'online' : 'offline'}`, title: online ? 'online' : 'offline' })
: h('span', { class: 'dot direct', title: 'direct connection' });
const meta = [];
if (isAgent) meta.push(online ? 'Online' : `Last seen ${ago(client.lastSeenAt)}`);
else meta.push(`${client.host}:${client.port}`);
if (client.os) meta.push(client.os);
const canConnect = !isAgent || online;
const actions = [
h('button', {
class: 'primary small',
text: 'Connect',
disabled: !canConnect,
onclick: () => connect(client, false),
}),
h('button', {
class: 'small',
text: 'View only',
disabled: !canConnect,
onclick: () => connect(client, true),
}),
];
if (state.isAdmin) {
actions.push(h('button', { class: 'ghost small', text: 'Share', onclick: () => newShareLink(client) }));
actions.push(h('button', { class: 'ghost small', text: '⋯', onclick: () => clientMenu(client) }));
}
return h('div', { class: 'card' },
h('div', { class: 'card-title' },
status,
h('h3', { text: client.name }),
h('div', { class: 'spacer' }),
client.requireConsent ? h('span', { class: 'tag warn', text: 'asks first' }) : null,
client.grantedRole ? h('span', { class: 'tag role', text: client.grantedRole }) : null),
client.description ? h('div', { class: 'faint', text: client.description }) : null,
h('div', { class: 'faint', text: meta.join(' · ') }),
client.tags.length ? h('div', { class: 'row wrap' }, client.tags.map((t) => h('span', { class: 'tag', text: t }))) : null,
h('div', { class: 'card-actions' }, ...actions));
}
function clientMenu(client) {
modal({
title: client.name,
body: h('div', { class: 'row wrap' },
h('button', { class: 'small', text: 'Edit', onclick: () => { $('#modal-root').innerHTML = ''; clientForm(client); } }),
h('button', { class: 'small', text: 'Who has access', onclick: () => { $('#modal-root').innerHTML = ''; grantsDialog(client); } }),
h('button', { class: 'small', text: 'Session history', onclick: () => { $('#modal-root').innerHTML = ''; historyDialog(client); } }),
h('button', {
class: 'small',
text: client.enrolled ? 'Re-issue agent key' : 'Issue agent key',
onclick: async () => {
if (client.enrolled && !confirm('The current agent will be disconnected and must be reconfigured. Continue?')) return;
const { agentKey } = await api(`/clients/${client.id}/agent-key`, { method: 'POST' });
$('#modal-root').innerHTML = '';
showLink('Agent key', agentKey, 'Put this in the agent config on that machine.');
refresh();
},
}),
h('button', {
class: 'small danger',
text: 'Delete',
onclick: async () => {
if (!confirm(`Delete "${client.name}"? Its grants and live sessions go with it.`)) return;
await api(`/clients/${client.id}`, { method: 'DELETE' });
$('#modal-root').innerHTML = '';
toast('Machine deleted');
refresh();
},
})),
});
}
function clientForm(client) {
const editing = !!client;
const c = client || { mode: 'direct', port: 5900, tags: [] };
const name = h('input', { value: c.name || '', required: true, placeholder: 'Reception PC' });
const description = h('input', { value: c.description || '', placeholder: 'Optional note' });
const host = h('input', { value: c.host || '', placeholder: '192.168.4.50' });
const port = h('input', { type: 'number', value: c.port || 5900, min: '1', max: '65535' });
const password = h('input', {
type: 'password',
placeholder: editing && c.hasPassword ? '•••••••• (unchanged)' : 'VNC password, if the server needs one',
});
const tags = h('input', { value: (c.tags || []).join(', '), placeholder: 'office, windows' });
const consent = h('input', { type: 'checkbox', ...(c.requireConsent ? { checked: true } : {}) });
const mode = h('select', {},
h('option', { value: 'direct', ...(c.mode === 'direct' ? { selected: true } : {}) }, 'Direct — the hub can reach its VNC port'),
h('option', { value: 'agent', ...(c.mode === 'agent' ? { selected: true } : {}) }, 'Agent — the machine dials out to the hub'));
const directFields = h('div', {},
h('div', { class: 'field-row' },
h('div', { class: 'field' }, h('label', { text: 'Host' }), host),
h('div', { class: 'field' }, h('label', { text: 'Port' }), port)));
const syncMode = () => { directFields.classList.toggle('hidden', mode.value !== 'direct'); };
mode.addEventListener('change', syncMode);
const error = h('div', { class: 'notice error hidden' });
const body = h('div', {},
error,
h('div', { class: 'field' }, h('label', { text: 'Name' }), name),
h('div', { class: 'field' }, h('label', { text: 'Description' }), description),
h('div', { class: 'field' }, h('label', { text: 'Connection' }), mode),
directFields,
h('div', { class: 'field' }, h('label', { text: 'VNC password' }), password),
h('div', { class: 'field' }, h('label', { text: 'Tags' }), tags),
h('div', { class: 'field' }, h('label', { class: 'check' }, consent, 'Ask the person at that machine before connecting')));
syncMode();
modal({
title: editing ? 'Edit machine' : 'Add machine',
body,
actions: (close) => [
h('button', { text: 'Cancel', onclick: close }),
h('button', {
class: 'primary',
text: editing ? 'Save' : 'Add',
onclick: async (e) => {
const button = e.currentTarget;
button.disabled = true;
error.classList.add('hidden');
const payload = {
name: name.value,
description: description.value,
mode: mode.value,
host: host.value,
port: Number(port.value) || 5900,
tags: tags.value,
requireConsent: consent.checked,
};
// Leaving the password blank on an edit keeps whatever is stored.
if (password.value || !editing) payload.vncPassword = password.value;
try {
if (editing) await api(`/clients/${client.id}`, { method: 'PATCH', body: payload });
else await api('/clients', { method: 'POST', body: payload });
close();
toast(editing ? 'Saved' : 'Machine added');
refresh();
} catch (err) {
error.textContent = err.message;
error.classList.remove('hidden');
button.disabled = false;
}
},
}),
],
});
}
async function grantsDialog(client) {
const { grants } = await api(`/clients/${client.id}/grants`);
const list = h('div', { class: 'table-wrap', style: 'margin-bottom:16px' });
const draw = (rows) => {
list.innerHTML = '';
if (!rows.length) {
list.append(h('div', { class: 'empty', text: 'Only admins can reach this machine.' }));
return;
}
list.append(h('table', {},
h('thead', {}, h('tr', {}, h('th', { text: 'User' }), h('th', { text: 'Role' }), h('th', { text: 'Expires' }), h('th', {}))),
h('tbody', {}, rows.map((g) => h('tr', {},
h('td', { text: g.username }),
h('td', { text: g.role }),
h('td', { class: 'faint', text: g.expires_at ? until(g.expires_at) : '—' }),
h('td', {}, h('button', {
class: 'ghost small danger',
text: 'Remove',
onclick: async () => {
await api(`/clients/${client.id}/grants/${encodeURIComponent(g.username)}`, { method: 'DELETE' });
draw(rows.filter((r) => r.username !== g.username));
},
})))))));
};
draw(grants);
const username = h('input', { placeholder: 'username' });
const role = h('select', {}, h('option', { value: 'viewer' }, 'View only'), h('option', { value: 'operator' }, 'Full control'));
modal({
title: `Access to ${client.name}`,
body: h('div', {},
list,
h('div', { class: 'field-row' },
h('div', { class: 'field' }, h('label', { text: 'Add user' }), username),
h('div', { class: 'field' }, h('label', { text: 'Role' }), role))),
actions: (close) => [
h('button', { text: 'Close', onclick: close }),
h('button', {
class: 'primary',
text: 'Grant access',
onclick: async () => {
if (!username.value.trim()) return;
await api(`/clients/${client.id}/grants`, {
method: 'POST',
body: { username: username.value.trim(), role: role.value },
});
const fresh = await api(`/clients/${client.id}/grants`);
username.value = '';
draw(fresh.grants);
toast('Access granted');
},
}),
],
});
}
async function historyDialog(client) {
const { sessions } = await api(`/sessions/history?clientId=${encodeURIComponent(client.id)}&limit=50`);
modal({
title: `${client.name} — sessions`,
body: sessions.length
? h('div', { class: 'table-wrap' }, sessionTable(sessions))
: h('div', { class: 'empty', text: 'No sessions recorded yet.' }),
});
}
/* -------------------------------------------------------------- invites */
const TTL_OPTIONS = [
['1 hour', 3600e3],
['8 hours', 8 * 3600e3],
['1 day', 24 * 3600e3],
['7 days', 7 * 24 * 3600e3],
['30 days', 30 * 24 * 3600e3],
];
function newEnrollLink() {
const name = h('input', { placeholder: 'leave blank to use the machine name' });
const tags = h('input', { placeholder: 'office, windows' });
const consent = h('input', { type: 'checkbox' });
const ttl = h('select', {}, TTL_OPTIONS.map(([label, ms], i) =>
h('option', { value: ms, ...(i === 2 ? { selected: true } : {}) }, label)));
modal({
title: 'Invite a machine',
body: h('div', {},
h('p', { class: 'faint', text: 'Send this to whoever is at the machine. Running the agent with it registers the machine here.' }),
h('div', { class: 'field' }, h('label', { text: 'Name it' }), name),
h('div', { class: 'field' }, h('label', { text: 'Tags' }), tags),
h('div', { class: 'field' }, h('label', { text: 'Link valid for' }), ttl),
h('div', { class: 'field' }, h('label', { class: 'check' }, consent, 'Ask the person there before each connection'))),
actions: (close) => [
h('button', { text: 'Cancel', onclick: close }),
h('button', {
class: 'primary',
text: 'Create link',
onclick: async () => {
const res = await api('/invites', {
method: 'POST',
body: {
kind: 'enroll',
name: name.value || undefined,
tags: tags.value,
requireConsent: consent.checked,
ttlMs: Number(ttl.value),
},
});
close();
showLink('Enrollment link', res.url, 'Open this on the machine you want to support.');
refresh();
},
}),
],
});
}
function newShareLink(preselect) {
const target = h('select', {}, state.clients.map((c) =>
h('option', { value: c.id, ...(preselect && c.id === preselect.id ? { selected: true } : {}) }, c.name)));
const role = h('select', {}, h('option', { value: 'viewer' }, 'View only'), h('option', { value: 'operator' }, 'Full control'));
const ttl = h('select', {}, TTL_OPTIONS.map(([label, ms], i) =>
h('option', { value: ms, ...(i === 0 ? { selected: true } : {}) }, label)));
const label = h('input', { placeholder: 'who is this for?' });
modal({
title: 'Support link',
body: h('div', {},
h('p', { class: 'faint', text: 'Anyone with this link can connect to that machine until it expires. No sign in needed.' }),
h('div', { class: 'field' }, h('label', { text: 'Machine' }), target),
h('div', { class: 'field' }, h('label', { text: 'They can' }), role),
h('div', { class: 'field' }, h('label', { text: 'Link valid for' }), ttl),
h('div', { class: 'field' }, h('label', { text: 'Label' }), label)),
actions: (close) => [
h('button', { text: 'Cancel', onclick: close }),
h('button', {
class: 'primary',
text: 'Create link',
onclick: async () => {
const res = await api('/invites', {
method: 'POST',
body: { kind: 'session', clientId: target.value, role: role.value, ttlMs: Number(ttl.value), label: label.value },
});
close();
showLink('Support link', res.url, 'Send this to the person who needs access.');
if (state.tab === 'invites') loadInvites();
},
}),
],
});
}
async function loadInvites() {
const container = $('#invites');
const { invites } = await api('/invites');
container.innerHTML = '';
if (!invites.length) {
container.append(h('div', { class: 'empty', text: 'No invite links yet.' }));
return;
}
container.append(h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {},
h('th', { text: 'Kind' }), h('th', { text: 'Target' }), h('th', { text: 'Label' }),
h('th', { text: 'Role' }), h('th', { text: 'Uses' }), h('th', { text: 'Expires' }),
h('th', { text: 'Status' }), h('th', {}))),
h('tbody', {}, invites.map((i) => h('tr', {},
h('td', { text: i.kind === 'enroll' ? 'Machine' : 'Support' }),
h('td', { text: i.clientName || '—' }),
h('td', { class: 'faint', text: i.label || '—' }),
h('td', { text: i.role || '—' }),
h('td', { text: i.maxUses ? `${i.uses}/${i.maxUses}` : String(i.uses) }),
h('td', { class: 'faint', text: until(i.expiresAt) }),
h('td', {}, h('span', { class: `tag ${i.status === 'active' ? 'role' : ''}`, text: i.status })),
h('td', {}, i.status === 'active'
? h('button', {
class: 'ghost small danger',
text: 'Revoke',
onclick: async () => {
await api(`/invites/${i.id}/revoke`, { method: 'POST' });
toast('Link revoked');
loadInvites();
},
})
: h('button', {
class: 'ghost small',
text: 'Delete',
onclick: async () => {
await api(`/invites/${i.id}`, { method: 'DELETE' });
loadInvites();
},
}))))))));
}
/* ------------------------------------------------------------- sessions */
function sessionTable(rows) {
return h('table', {},
h('thead', {}, h('tr', {},
h('th', { text: 'Machine' }), h('th', { text: 'Who' }), h('th', { text: 'Role' }),
h('th', { text: 'Started' }), h('th', { text: 'Length' }), h('th', { text: 'Traffic' }), h('th', { text: 'Ended' }))),
h('tbody', {}, rows.map((s) => h('tr', {},
h('td', { text: s.client_name || s.clientName || '—' }),
h('td', { text: s.username }),
h('td', {}, h('span', { class: 'tag', text: s.role })),
h('td', { class: 'faint', text: ago(s.started_at || s.startedAt) }),
h('td', { class: 'faint', text: duration(s.started_at || s.startedAt, s.ended_at) }),
h('td', { class: 'faint', text: bytes((s.bytes_in || 0) + (s.bytes_out || 0)) }),
h('td', { class: 'faint', text: s.end_reason || '—' })))));
}
async function loadSessions() {
const liveBox = $('#live-sessions');
const histBox = $('#session-history');
const { sessions: liveRows } = await api('/sessions/live');
liveBox.innerHTML = '';
if (!liveRows.length) {
liveBox.append(h('div', { class: 'empty', text: 'Nobody is connected right now.' }));
} else {
liveBox.append(h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {},
h('th', { text: 'Machine' }), h('th', { text: 'Who' }), h('th', { text: 'Role' }),
h('th', { text: 'Source' }), h('th', { text: 'For' }), h('th', { text: 'Traffic' }), h('th', {}))),
h('tbody', {}, liveRows.map((s) => h('tr', {},
h('td', {}, h('span', { class: 'row' }, h('span', { class: 'dot online' }), s.clientName)),
h('td', { text: s.username }),
h('td', {}, h('span', { class: 'tag role', text: s.role })),
h('td', { class: 'faint', text: s.source }),
h('td', { class: 'faint', text: duration(s.startedAt) }),
h('td', { class: 'faint', text: bytes(s.bytesIn + s.bytesOut) }),
h('td', {}, state.isAdmin
? h('button', {
class: 'ghost small danger',
text: 'Disconnect',
onclick: async () => {
await api(`/sessions/${s.id}/kill`, { method: 'POST' });
toast('Session ended');
loadSessions();
},
})
: null)))))));
}
if (!state.isAdmin) {
histBox.innerHTML = '';
histBox.append(h('div', { class: 'empty', text: 'History is admin-only.' }));
return;
}
const { sessions: history } = await api('/sessions/history?limit=100');
histBox.innerHTML = '';
histBox.append(history.length
? h('div', { class: 'table-wrap' }, sessionTable(history))
: h('div', { class: 'empty', text: 'No sessions recorded yet.' }));
}
async function loadAudit() {
const box = $('#audit');
const { audit } = await api('/sessions/audit?limit=200');
box.innerHTML = '';
box.append(audit.length
? h('div', { class: 'table-wrap' }, h('table', {},
h('thead', {}, h('tr', {}, h('th', { text: 'When' }), h('th', { text: 'Who' }), h('th', { text: 'Action' }), h('th', { text: 'Target' }), h('th', { text: 'Detail' }))),
h('tbody', {}, audit.map((a) => h('tr', {},
h('td', { class: 'faint', text: ago(a.ts) }),
h('td', { text: a.username || '—' }),
h('td', {}, h('span', { class: 'tag', text: a.action })),
h('td', { class: 'mono dim', text: (a.target || '—').slice(0, 8) }),
h('td', { class: 'faint', text: a.detail || '' }))))))
: h('div', { class: 'empty', text: 'Nothing logged yet.' }));
}
/* ------------------------------------------------------------- shell */
async function refresh() {
try {
const { clients, isAdmin } = await api('/clients');
state.clients = clients;
state.isAdmin = isAdmin;
const grid = $('#machines');
grid.innerHTML = '';
$('#machines-count').textContent = clients.length ? `${clients.length} total` : '';
if (!clients.length) {
grid.append(h('div', { class: 'empty', style: 'grid-column:1/-1' },
state.isAdmin
? 'No machines yet. Add one directly, or send an enrollment link.'
: 'Nobody has given you access to a machine yet.'));
} else {
for (const c of clients) grid.append(clientCard(c));
}
for (const el of document.querySelectorAll('[data-admin-only]')) el.classList.toggle('hidden', !state.isAdmin);
} catch (err) {
toast(err.message);
}
}
function selectTab(tab) {
state.tab = tab;
for (const b of document.querySelectorAll('#tabs button')) b.classList.toggle('active', b.dataset.tab === tab);
for (const name of ['machines', 'invites', 'sessions', 'audit']) {
$(`#tab-${name}`).classList.toggle('hidden', name !== tab);
}
if (tab === 'machines') refresh();
if (tab === 'invites') loadInvites().catch((e) => toast(e.message));
if (tab === 'sessions') loadSessions().catch((e) => toast(e.message));
if (tab === 'audit') loadAudit().catch((e) => toast(e.message));
}
function showApp() {
$('#login').classList.add('hidden');
$('#app').classList.remove('hidden');
$('#whoami').textContent = state.isAdmin ? `${state.username} · admin` : state.username;
for (const el of [$('#btn-add-client'), $('#btn-enroll-invite'), $('#btn-new-enroll'), $('#btn-new-share')]) {
el.classList.toggle('hidden', !state.isAdmin);
}
$('#tabs').querySelector('[data-tab="audit"]').classList.toggle('hidden', !state.isAdmin);
selectTab('machines');
}
async function boot() {
$('#login-form').addEventListener('submit', signIn);
$('#logout').addEventListener('click', signOut);
$('#tabs').addEventListener('click', (e) => {
const tab = e.target.closest('button')?.dataset.tab;
if (tab) selectTab(tab);
});
$('#btn-add-client').addEventListener('click', () => clientForm(null));
$('#btn-enroll-invite').addEventListener('click', newEnrollLink);
$('#btn-new-enroll').addEventListener('click', newEnrollLink);
$('#btn-new-share').addEventListener('click', () => newShareLink(null));
$('#btn-refresh-sessions').addEventListener('click', () => loadSessions().catch((e) => toast(e.message)));
if (!state.token) return $('#login').classList.remove('hidden');
try {
const me = await api('/me');
state.username = me.username;
state.isAdmin = me.isAdmin;
showApp();
} catch {
$('#login').classList.remove('hidden');
}
// Keep the machine list and any open session view roughly current.
setInterval(() => {
if (document.hidden || $('#app').classList.contains('hidden')) return;
if (state.tab === 'machines') refresh();
if (state.tab === 'sessions') loadSessions().catch(() => {});
}, 15_000);
}
boot();
+115
View File
@@ -0,0 +1,115 @@
<!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>
+165
View File
@@ -0,0 +1,165 @@
<!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>Remote Support</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<!-- ambient particle field: login screen full strength, console dimmed ------ -->
<div class="bg-field" id="bg-field" aria-hidden="true"></div>
<!-- login ---------------------------------------------------------------- -->
<div id="login" class="login-shell hidden">
<section class="login-aside">
<div class="brand">
<div class="brand-mark">RC</div>
<div>
<h1>Remote Support</h1>
<span class="brand-sub">Operator console</span>
</div>
</div>
<h2 class="login-headline">Sit down at any desk<br>on the <em>network</em>.</h2>
<p class="login-lede">
Screen sharing and remote control for the machines you look after — running
on your own hardware, on your own wire. Nothing leaves the building.
</p>
<ul class="login-facts">
<li>Self-hosted · no cloud relay</li>
<li>Consent prompt on the remote desk</li>
<li>Every session recorded in the audit log</li>
</ul>
</section>
<form class="login-card" id="login-form">
<div class="brand card-brand">
<div class="brand-mark">RC</div>
<div>
<h1>Remote Support</h1>
<span class="brand-sub">Operator console</span>
</div>
</div>
<h2 class="form-title">Sign in</h2>
<p class="faint form-note">Use your usual account.</p>
<div id="login-error" class="notice error hidden"></div>
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required autofocus>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button class="primary" type="submit" id="login-button">Sign in</button>
<p class="login-foot">Sessions expire after one hour</p>
</form>
</div>
<!-- app ------------------------------------------------------------------ -->
<div id="app" class="hidden">
<header class="topbar">
<div class="brand">
<div class="brand-mark">RC</div>
<div>
<h1>Remote Support</h1>
<span class="brand-sub">Operator console</span>
</div>
</div>
<div class="topbar-divider"></div>
<nav class="tabs" id="tabs">
<button data-tab="machines" class="active">Machines</button>
<button data-tab="invites">Invites</button>
<button data-tab="sessions">Sessions</button>
<button data-tab="audit">Audit</button>
</nav>
<div class="spacer"></div>
<span class="faint" id="whoami"></span>
<button class="ghost small" id="logout">Sign out</button>
</header>
<main>
<!-- machines -->
<section id="tab-machines">
<div class="panel-head">
<h2>Machines</h2>
<span class="faint" id="machines-count"></span>
<div class="spacer"></div>
<button class="small" id="btn-enroll-invite">Invite a machine</button>
<button class="primary small" id="btn-add-client">Add machine</button>
</div>
<div id="machines" class="grid"></div>
</section>
<!-- invites -->
<section id="tab-invites" class="hidden">
<div class="panel-head">
<h2>Invite links</h2>
<div class="spacer"></div>
<button class="small" id="btn-new-enroll">New enrollment link</button>
<button class="primary small" id="btn-new-share">New support link</button>
</div>
<div id="invites"></div>
</section>
<!-- sessions -->
<section id="tab-sessions" class="hidden">
<div class="panel-head">
<h2>Live now</h2>
<div class="spacer"></div>
<button class="ghost small" id="btn-refresh-sessions">Refresh</button>
</div>
<div id="live-sessions"></div>
<div class="panel-head" style="margin-top:34px">
<h2>History</h2>
</div>
<div id="session-history"></div>
</section>
<!-- audit -->
<section id="tab-audit" class="hidden">
<div class="panel-head"><h2>Audit log</h2></div>
<div id="audit"></div>
</section>
</main>
</div>
<div id="modal-root"></div>
<script src="/app.js"></script>
<!-- Particle field. Purely decorative; app.js is untouched by this. -->
<script type="module">
import { mountParticles } from '/particles.js';
const layer = document.getElementById('bg-field');
const field = mountParticles(layer);
// app.js flips .hidden on #login / #app. Mirror that onto <body data-screen>
// so the field can dim itself, and freeze the animation inside the console.
const app = document.getElementById('app');
const sync = () => {
const inConsole = !app.classList.contains('hidden');
document.body.dataset.screen = inConsole ? 'console' : 'login';
field.setMode(inConsole ? 'static' : 'animate');
};
new MutationObserver(sync).observe(app, { attributes: true, attributeFilter: ['class'] });
sync();
</script>
</body>
</html>
+273
View File
@@ -0,0 +1,273 @@
/* ============================================================================
particles.js — ambient "network of machines" field.
Self-contained, no dependencies. Mounts a <canvas> into a host element and
draws slow-drifting nodes with proximity links.
import { mountParticles } from '/particles.js';
const field = mountParticles(document.getElementById('bg-field'));
field.setMode('static'); // freeze: draws one frame, then zero cost
field.destroy();
Budget rules baked in, because this runs on whatever desktop is on the desk:
- node count is derived from viewport area and hard-capped
- device pixel ratio is capped at 2
- the loop is throttled to ~30fps and uses no shadows or gradients
- rAF is cancelled outright when the tab is hidden or the mode is static
- prefers-reduced-motion renders a single static frame and never loops
========================================================================= */
'use strict';
const DEFAULTS = {
/* one node per this many CSS pixels of area */
areaPerNode: 26000,
minNodes: 14,
maxNodes: 78,
/* proximity links */
linkDistance: 138,
linkAlpha: 0.20,
/* nodes */
nodeAlpha: 0.62,
nodeSize: 2,
hubEvery: 7, /* every Nth node is drawn as a larger "hub" */
/* drift, CSS px per second */
speed: 7,
fps: 30,
colors: ['#3ddc97', '#3ddc97', '#3ddc97', '#9aa2ff', '#e9a05c'],
linkColor: '61, 220, 151',
mode: 'animate',
};
const NOOP_HANDLE = {
setMode() {},
destroy() {},
canvas: null,
};
export function mountParticles(host, options = {}) {
if (!host || typeof document === 'undefined') return NOOP_HANDLE;
const cfg = { ...DEFAULTS, ...options };
const canvas = document.createElement('canvas');
canvas.setAttribute('aria-hidden', 'true');
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
if (!ctx) return NOOP_HANDLE;
host.append(canvas);
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let nodes = [];
let width = 0;
let height = 0;
let raf = 0;
let lastFrame = 0;
let mode = cfg.mode;
let destroyed = false;
/* ------------------------------------------------------------ geometry */
function targetCount() {
const raw = Math.round((width * height) / cfg.areaPerNode);
return Math.max(cfg.minNodes, Math.min(cfg.maxNodes, raw));
}
function makeNode(index) {
const angle = Math.random() * Math.PI * 2;
return {
x: Math.random() * width,
y: Math.random() * height,
vx: Math.cos(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
vy: Math.sin(angle) * cfg.speed * (0.35 + Math.random() * 0.85),
color: cfg.colors[index % cfg.colors.length],
hub: index % cfg.hubEvery === 0,
/* per-node brightness keeps the field from looking like a lattice */
alpha: cfg.nodeAlpha * (0.45 + Math.random() * 0.55),
};
}
function reconcileNodes() {
const want = targetCount();
while (nodes.length > want) nodes.pop();
while (nodes.length < want) nodes.push(makeNode(nodes.length));
for (const n of nodes) {
if (n.x > width) n.x = Math.random() * width;
if (n.y > height) n.y = Math.random() * height;
}
}
function resize() {
if (destroyed) return;
const w = host.clientWidth || window.innerWidth;
const h = host.clientHeight || window.innerHeight;
if (!w || !h) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = w;
height = h;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
reconcileNodes();
if (!running()) draw();
}
/* --------------------------------------------------------------- paint */
function draw() {
ctx.clearRect(0, 0, width, height);
/* links first, so nodes sit on top of them */
const max = cfg.linkDistance;
const maxSq = max * max;
ctx.lineWidth = 1;
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const distSq = dx * dx + dy * dy;
if (distSq > maxSq) continue;
const strength = 1 - Math.sqrt(distSq) / max;
ctx.strokeStyle = `rgba(${cfg.linkColor}, ${(strength * strength * cfg.linkAlpha).toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
/* nodes: small squares read as devices, not as bokeh */
ctx.globalAlpha = 1;
for (const n of nodes) {
const s = n.hub ? cfg.nodeSize + 1.5 : cfg.nodeSize;
ctx.fillStyle = withAlpha(n.color, n.alpha);
ctx.fillRect(n.x - s / 2, n.y - s / 2, s, s);
if (n.hub) {
ctx.strokeStyle = withAlpha(n.color, n.alpha * 0.30);
ctx.strokeRect(n.x - s * 1.6, n.y - s * 1.6, s * 3.2, s * 3.2);
}
}
}
function step(now) {
raf = window.requestAnimationFrame(step);
const interval = 1000 / cfg.fps;
const elapsed = now - lastFrame;
if (elapsed < interval) return;
/* keep the phase, but never integrate a huge dt after a stall */
lastFrame = now - (elapsed % interval);
const dt = Math.min(elapsed, 100) / 1000;
for (const n of nodes) {
n.x += n.vx * dt;
n.y += n.vy * dt;
if (n.x < 0) { n.x = 0; n.vx = -n.vx; }
else if (n.x > width) { n.x = width; n.vx = -n.vx; }
if (n.y < 0) { n.y = 0; n.vy = -n.vy; }
else if (n.y > height) { n.y = height; n.vy = -n.vy; }
}
draw();
}
/* ------------------------------------------------------------ lifecycle */
function running() { return raf !== 0; }
function shouldAnimate() {
return !destroyed
&& mode === 'animate'
&& !document.hidden
&& !motionQuery.matches;
}
function stop() {
if (raf) window.cancelAnimationFrame(raf);
raf = 0;
}
function sync() {
if (shouldAnimate()) {
if (!running()) {
lastFrame = performance.now();
raf = window.requestAnimationFrame(step);
}
return;
}
stop();
if (!destroyed && mode !== 'off') draw();
if (mode === 'off') ctx.clearRect(0, 0, width, height);
}
/* ------------------------------------------------------------- plumbing */
let resizeTimer = 0;
const onResize = () => {
window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(resize, 140);
};
const onVisibility = () => sync();
window.addEventListener('resize', onResize, { passive: true });
document.addEventListener('visibilitychange', onVisibility);
addMediaListener(motionQuery, sync);
let observer = null;
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(onResize);
observer.observe(host);
}
resize();
sync();
return {
canvas,
/** 'animate' | 'static' (one frozen frame) | 'off' (blank) */
setMode(next) {
if (next === mode) return;
mode = next;
sync();
},
destroy() {
destroyed = true;
stop();
window.clearTimeout(resizeTimer);
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVisibility);
removeMediaListener(motionQuery, sync);
observer?.disconnect();
canvas.remove();
},
};
}
/* ------------------------------------------------------------------ utils */
function withAlpha(hex, alpha) {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`;
}
/* Safari < 14 only has the deprecated listener API. */
function addMediaListener(query, fn) {
if (query.addEventListener) query.addEventListener('change', fn);
else if (query.addListener) query.addListener(fn);
}
function removeMediaListener(query, fn) {
if (query.removeEventListener) query.removeEventListener('change', fn);
else if (query.removeListener) query.removeListener(fn);
}
export default mountParticles;
+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>
+1021
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
<!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>Remote session</title>
<link rel="stylesheet" href="/styles.css">
</head>
<!-- No particle field here: every frame belongs to the remote desktop. -->
<body class="viewer-body">
<div class="viewer-bar">
<span class="dot" id="conn-dot"></span>
<strong id="client-name">Connecting…</strong>
<span class="tag" id="role-tag" hidden></span>
<span class="viewer-sep"></span>
<span class="faint" id="conn-status"></span>
<div class="spacer"></div>
<button class="small" id="btn-cad" disabled title="Send Ctrl-Alt-Delete">Ctrl-Alt-Del</button>
<button class="small" id="btn-clipboard" disabled title="Send clipboard text to the remote machine">Paste</button>
<button class="small" id="btn-fit" disabled title="Toggle between fit-to-window and actual size">Fit</button>
<button class="small" id="btn-fullscreen" disabled>Fullscreen</button>
<span class="viewer-sep"></span>
<button class="small danger" id="btn-disconnect" disabled>Disconnect</button>
</div>
<div id="screen" style="position:relative">
<div class="viewer-status" id="status-overlay">
<div class="box">
<h2 id="status-title">Connecting…</h2>
<p class="faint" id="status-text" style="margin-bottom:0">Setting up the session.</p>
<div id="status-actions" class="row" style="justify-content:center;margin-top:18px"></div>
</div>
</div>
</div>
<script type="module" src="/viewer.js"></script>
</body>
</html>
+159
View File
@@ -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 = '/'; } },
]);
}
})();