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,36 @@
|
||||
'use strict';
|
||||
|
||||
// One-time, short-lived tickets for WebSocket connects.
|
||||
//
|
||||
// A browser cannot set an Authorization header on a WebSocket, and putting a
|
||||
// 1-hour session JWT in a query string leaks it into proxy and access logs. So
|
||||
// the REST layer mints a ticket that is single-use and expires in seconds, and
|
||||
// the WebSocket carries only that.
|
||||
|
||||
const { randomToken } = require('./crypto');
|
||||
const config = require('./config');
|
||||
|
||||
const tickets = new Map(); // token -> { payload, expiresAt }
|
||||
|
||||
function issue(payload, ttlMs = config.ticketTtlMs) {
|
||||
const token = randomToken(24);
|
||||
tickets.set(token, { payload, expiresAt: Date.now() + ttlMs });
|
||||
return { token, expiresIn: Math.floor(ttlMs / 1000) };
|
||||
}
|
||||
|
||||
function redeem(token) {
|
||||
if (!token) return null;
|
||||
const entry = tickets.get(token);
|
||||
if (!entry) return null;
|
||||
tickets.delete(token); // single use, redeemed or not
|
||||
if (entry.expiresAt < Date.now()) return null;
|
||||
return entry.payload;
|
||||
}
|
||||
|
||||
const sweep = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, entry] of tickets) if (entry.expiresAt < now) tickets.delete(token);
|
||||
}, 60_000);
|
||||
sweep.unref?.();
|
||||
|
||||
module.exports = { issue, redeem };
|
||||
Reference in New Issue
Block a user