feat: self-hosted remote support over VNC
Browser-based remote control (noVNC) with invite links, per-user access control, garagedoor SSO and a persisted client list. The hub proxies RFB rather than pointing the browser at a VNC server. That is what lets it authenticate upstream with a stored password the browser never sees, and enforce view-only by dropping input messages on the client->server stream instead of hiding buttons. Machines are reachable two ways: direct TCP for LAN hosts, or an outbound agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the agent dependency-free, and node:sqlite keeps the image free of native builds. Ships with an end-to-end suite that boots the real server against a fake VNC server and a fake auth service (72 assertions), plus Gitea Actions CI/CD to Portainer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
Reference in New Issue
Block a user