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:
+718
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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