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>
216 lines
6.6 KiB
JavaScript
216 lines
6.6 KiB
JavaScript
'use strict';
|
|
|
|
// Agent registry and tunnel broker.
|
|
//
|
|
// Agent-mode clients sit behind NAT, so they dial *out* to the hub and hold a
|
|
// control WebSocket open. When someone wants to view that machine, the hub asks
|
|
// the agent over that control channel to open a second, data-only WebSocket; the
|
|
// agent pipes it to the local VNC server. The hub pairs that data socket with
|
|
// the waiting browser session.
|
|
|
|
const { createWebSocketStream } = require('ws');
|
|
const config = require('../config');
|
|
const { clients, audit } = require('../db');
|
|
const { randomToken } = require('../crypto');
|
|
|
|
const HEARTBEAT_MS = 30_000;
|
|
|
|
class Hub {
|
|
constructor() {
|
|
/** clientId -> { ws, info, connectedAt, lastSeen, alive } */
|
|
this.agents = new Map();
|
|
/** tunnelId -> { clientId, resolve, reject, timer } */
|
|
this.pending = new Map();
|
|
|
|
this.heartbeat = setInterval(() => this._sweep(), HEARTBEAT_MS);
|
|
this.heartbeat.unref?.();
|
|
}
|
|
|
|
/* ------------------------------------------------------------- control */
|
|
|
|
handleAgentSocket(ws, client, remoteIp) {
|
|
// A machine may only have one live control channel; a reconnect wins.
|
|
const existing = this.agents.get(client.id);
|
|
if (existing && existing.ws !== ws) {
|
|
try { existing.ws.close(4001, 'replaced by a newer connection'); } catch { /* already gone */ }
|
|
}
|
|
|
|
const entry = { ws, info: {}, connectedAt: Date.now(), lastSeen: Date.now(), alive: true, lastIp: remoteIp };
|
|
this.agents.set(client.id, entry);
|
|
clients.touch(client.id, remoteIp);
|
|
|
|
ws.on('pong', () => {
|
|
entry.alive = true;
|
|
entry.lastSeen = Date.now();
|
|
clients.touch(client.id, remoteIp);
|
|
});
|
|
|
|
ws.on('message', (raw) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(raw.toString());
|
|
} catch {
|
|
return;
|
|
}
|
|
entry.lastSeen = Date.now();
|
|
this._onAgentMessage(client, entry, msg, remoteIp);
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
if (this.agents.get(client.id) === entry) this.agents.delete(client.id);
|
|
// Fail anything that was waiting on this agent.
|
|
for (const [tunnelId, p] of this.pending) {
|
|
if (p.clientId === client.id) this._rejectPending(tunnelId, new Error('agent disconnected'));
|
|
}
|
|
});
|
|
|
|
ws.on('error', () => { /* close handler does the cleanup */ });
|
|
|
|
this._send(ws, {
|
|
type: 'welcome',
|
|
clientId: client.id,
|
|
name: client.name,
|
|
requireConsent: !!client.require_consent,
|
|
heartbeatMs: HEARTBEAT_MS,
|
|
});
|
|
}
|
|
|
|
_onAgentMessage(client, entry, msg, remoteIp) {
|
|
switch (msg.type) {
|
|
case 'hello': {
|
|
entry.info = {
|
|
version: msg.version,
|
|
os: msg.os,
|
|
hostname: msg.hostname,
|
|
vncPort: msg.vncPort,
|
|
};
|
|
clients.update(client.id, {
|
|
os: msg.os ?? null,
|
|
hostname: msg.hostname ?? null,
|
|
agent_version: msg.version ?? null,
|
|
last_seen_at: Date.now(),
|
|
last_ip: remoteIp ?? null,
|
|
});
|
|
break;
|
|
}
|
|
case 'denied': {
|
|
this._rejectPending(msg.tunnelId, new Error(msg.reason || 'the person at that machine declined'));
|
|
break;
|
|
}
|
|
case 'error': {
|
|
this._rejectPending(msg.tunnelId, new Error(msg.message || 'agent reported an error'));
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
_send(ws, obj) {
|
|
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
|
}
|
|
|
|
_sweep() {
|
|
const now = Date.now();
|
|
for (const [clientId, entry] of this.agents) {
|
|
if (!entry.alive) {
|
|
try { entry.ws.terminate(); } catch { /* already gone */ }
|
|
this.agents.delete(clientId);
|
|
continue;
|
|
}
|
|
entry.alive = false;
|
|
try { entry.ws.ping(); } catch { /* handled on next sweep */ }
|
|
if (now - entry.lastSeen < config.agentOfflineAfterMs) clients.touch(clientId, entry.lastIp);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------------------------- status */
|
|
|
|
isOnline(clientId) {
|
|
return this.agents.has(clientId);
|
|
}
|
|
|
|
onlineIds() {
|
|
return Array.from(this.agents.keys());
|
|
}
|
|
|
|
agentInfo(clientId) {
|
|
const e = this.agents.get(clientId);
|
|
return e ? { ...e.info, connectedAt: e.connectedAt, lastSeen: e.lastSeen } : null;
|
|
}
|
|
|
|
/* ------------------------------------------------------------- tunnels */
|
|
|
|
/**
|
|
* Ask an agent to open a data tunnel. Resolves with a Duplex carrying the raw
|
|
* RFB byte stream from the client machine's local VNC server.
|
|
*/
|
|
openTunnel(clientId, meta = {}) {
|
|
const entry = this.agents.get(clientId);
|
|
if (!entry) return Promise.reject(new Error('that machine is offline'));
|
|
|
|
const tunnelId = randomToken(16);
|
|
const requireConsent = !!meta.requireConsent;
|
|
const timeoutMs = requireConsent ? config.consentTimeoutMs : 15_000;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pending.delete(tunnelId);
|
|
reject(new Error(requireConsent
|
|
? 'no response to the connection request on that machine'
|
|
: 'the agent did not open a tunnel in time'));
|
|
}, timeoutMs);
|
|
timer.unref?.();
|
|
|
|
this.pending.set(tunnelId, { clientId, resolve, reject, timer });
|
|
|
|
this._send(entry.ws, {
|
|
type: 'open',
|
|
tunnelId,
|
|
requireConsent,
|
|
operator: meta.operator || 'someone',
|
|
role: meta.role || 'viewer',
|
|
sessionId: meta.sessionId,
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Called when the agent's data socket arrives and claims a pending tunnel. */
|
|
handleTunnelSocket(ws, clientId, tunnelId) {
|
|
const pending = this.pending.get(tunnelId);
|
|
if (!pending || pending.clientId !== clientId) {
|
|
try { ws.close(4004, 'unknown tunnel'); } catch { /* nothing to do */ }
|
|
return false;
|
|
}
|
|
clearTimeout(pending.timer);
|
|
this.pending.delete(tunnelId);
|
|
pending.resolve(createWebSocketStream(ws, { allowHalfOpen: false }));
|
|
return true;
|
|
}
|
|
|
|
_rejectPending(tunnelId, err) {
|
|
const pending = this.pending.get(tunnelId);
|
|
if (!pending) return;
|
|
clearTimeout(pending.timer);
|
|
this.pending.delete(tunnelId);
|
|
pending.reject(err);
|
|
}
|
|
|
|
/** Tell an agent to drop whatever it is doing (used by force-disconnect). */
|
|
notifySessionEnded(clientId, sessionId) {
|
|
const entry = this.agents.get(clientId);
|
|
if (entry) this._send(entry.ws, { type: 'session-ended', sessionId });
|
|
}
|
|
|
|
disconnectAgent(clientId, reason = 'removed') {
|
|
const entry = this.agents.get(clientId);
|
|
if (!entry) return false;
|
|
try { entry.ws.close(4003, reason); } catch { /* already gone */ }
|
|
this.agents.delete(clientId);
|
|
audit(null, 'agent.disconnect', clientId, { reason });
|
|
return true;
|
|
}
|
|
}
|
|
|
|
module.exports = new Hub();
|