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,78 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { clients, sessions, audit } = require('../db');
|
||||
const { uuid } = require('../crypto');
|
||||
const { requireAuth, requireAdmin, roleForClient } = require('../auth');
|
||||
const tickets = require('../tickets');
|
||||
const bridge = require('../vnc/bridge');
|
||||
const hub = require('../vnc/hub');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
/** Mint a one-time ticket for the VNC WebSocket. This is the connect handshake. */
|
||||
router.post('/', (req, res) => {
|
||||
const client = clients.get(String((req.body || {}).clientId || ''));
|
||||
if (!client) return res.status(404).json({ error: 'no such client' });
|
||||
|
||||
const role = roleForClient(req.user, client.id, req.isAdmin);
|
||||
if (!role) return res.status(403).json({ error: 'you do not have access to this client' });
|
||||
|
||||
// A viewer can deliberately drop to view-only, but never upgrade past its grant.
|
||||
const requested = (req.body || {}).viewOnly ? 'viewer' : role;
|
||||
|
||||
if (client.mode === 'agent' && !hub.isOnline(client.id)) {
|
||||
return res.status(409).json({ error: 'that machine is offline' });
|
||||
}
|
||||
|
||||
const sessionId = uuid();
|
||||
const ticket = tickets.issue({
|
||||
sessionId,
|
||||
clientId: client.id,
|
||||
username: req.username,
|
||||
role: requested,
|
||||
source: 'web',
|
||||
});
|
||||
|
||||
res.json({
|
||||
ticket: ticket.token,
|
||||
expiresIn: ticket.expiresIn,
|
||||
sessionId,
|
||||
role: requested,
|
||||
clientName: client.name,
|
||||
requireConsent: !!client.require_consent,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/live', (req, res) => {
|
||||
const all = bridge.listLive();
|
||||
res.json({ sessions: req.isAdmin ? all : all.filter((s) => s.username === req.username) });
|
||||
});
|
||||
|
||||
router.get('/history', (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 100, 500);
|
||||
const clientId = req.query.clientId ? String(req.query.clientId) : null;
|
||||
|
||||
if (!req.isAdmin) {
|
||||
if (!clientId) return res.status(403).json({ error: 'admin only' });
|
||||
if (!roleForClient(req.user, clientId, false)) {
|
||||
return res.status(403).json({ error: 'you do not have access to this client' });
|
||||
}
|
||||
}
|
||||
res.json({ sessions: sessions.recent(limit, clientId) });
|
||||
});
|
||||
|
||||
router.post('/:id/kill', requireAdmin, (req, res) => {
|
||||
const ok = bridge.killSession(req.params.id, `disconnected by ${req.username}`);
|
||||
if (!ok) return res.status(404).json({ error: 'no such live session' });
|
||||
audit(req.username, 'session.kill', req.params.id, null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/audit', requireAdmin, (req, res) => {
|
||||
res.json({ audit: audit.recent(Math.min(Number(req.query.limit) || 200, 1000)) });
|
||||
});
|
||||
|
||||
module.exports = { router };
|
||||
Reference in New Issue
Block a user