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,136 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const config = require('../config');
|
||||
const { invites, clients, audit } = require('../db');
|
||||
const { uuid, randomToken, sha256 } = require('../crypto');
|
||||
const { requireAuth, requireAdmin } = require('../auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function baseUrl(req) {
|
||||
if (config.publicUrl) return config.publicUrl;
|
||||
const proto = req.headers['x-forwarded-proto'] || req.protocol;
|
||||
return `${proto}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
function inviteLink(req, kind, token) {
|
||||
return `${baseUrl(req)}/${kind === 'enroll' ? 'enroll' : 's'}/${token}`;
|
||||
}
|
||||
|
||||
function publicInvite(i) {
|
||||
return {
|
||||
id: i.id,
|
||||
kind: i.kind,
|
||||
clientId: i.client_id,
|
||||
clientName: i.client_name,
|
||||
label: i.label,
|
||||
role: i.role,
|
||||
maxUses: i.max_uses,
|
||||
uses: i.uses,
|
||||
expiresAt: i.expires_at,
|
||||
revokedAt: i.revoked_at,
|
||||
lastUsedAt: i.last_used_at,
|
||||
createdBy: i.created_by,
|
||||
createdAt: i.created_at,
|
||||
status: i.revoked_at
|
||||
? 'revoked'
|
||||
: i.expires_at && i.expires_at < Date.now()
|
||||
? 'expired'
|
||||
: i.max_uses > 0 && i.uses >= i.max_uses
|
||||
? 'used up'
|
||||
: 'active',
|
||||
};
|
||||
}
|
||||
|
||||
router.use(requireAuth, requireAdmin);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json({ invites: invites.list().map(publicInvite) });
|
||||
});
|
||||
|
||||
/**
|
||||
* Two kinds of invite:
|
||||
* - enroll: hand to a *machine*. Redeeming it registers a new client.
|
||||
* - session: hand to a *person*. Redeeming it grants time-boxed access to one client.
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
const body = req.body || {};
|
||||
const kind = body.kind === 'enroll' ? 'enroll' : 'session';
|
||||
|
||||
let clientId = null;
|
||||
let role = null;
|
||||
let prefill = null;
|
||||
|
||||
if (kind === 'session') {
|
||||
if (!config.allowSessionInvites) {
|
||||
return res.status(403).json({ error: 'session invites are disabled on this server' });
|
||||
}
|
||||
const client = clients.get(String(body.clientId || ''));
|
||||
if (!client) return res.status(400).json({ error: 'a valid clientId is required for a session invite' });
|
||||
clientId = client.id;
|
||||
role = body.role === 'operator' ? 'operator' : 'viewer';
|
||||
} else {
|
||||
// Defaults applied to whatever machine redeems this enrollment link.
|
||||
prefill = JSON.stringify({
|
||||
name: body.name ? String(body.name).trim() : null,
|
||||
tags: body.tags ? String(body.tags) : '',
|
||||
requireConsent: body.requireConsent ? 1 : 0,
|
||||
});
|
||||
if (body.clientId) {
|
||||
// Re-enrolling an existing client (replace a machine, rotate its key).
|
||||
const client = clients.get(String(body.clientId));
|
||||
if (!client) return res.status(400).json({ error: 'no such client' });
|
||||
clientId = client.id;
|
||||
}
|
||||
}
|
||||
|
||||
const ttlMs = Number(body.ttlMs) > 0 ? Number(body.ttlMs) : config.inviteDefaultTtlMs;
|
||||
// An enrollment link is meant for exactly one machine. A support link should
|
||||
// survive a dropped connection, so it defaults to unlimited uses until it expires.
|
||||
const defaultMaxUses = kind === 'enroll' ? 1 : 0;
|
||||
const maxUses = body.maxUses === undefined || body.maxUses === null || body.maxUses === ''
|
||||
? defaultMaxUses
|
||||
: Math.max(0, Number.parseInt(body.maxUses, 10) || 0);
|
||||
|
||||
const token = randomToken(32);
|
||||
const invite = invites.create({
|
||||
id: uuid(),
|
||||
token_hash: sha256(token),
|
||||
kind,
|
||||
client_id: clientId,
|
||||
label: body.label ? String(body.label).slice(0, 200) : null,
|
||||
role,
|
||||
prefill,
|
||||
max_uses: maxUses,
|
||||
expires_at: Date.now() + ttlMs,
|
||||
created_by: req.username,
|
||||
});
|
||||
|
||||
audit(req.username, 'invite.create', invite.id, { kind, clientId, role, maxUses, ttlMs });
|
||||
|
||||
// The token itself is shown exactly once; only its hash is persisted.
|
||||
res.status(201).json({
|
||||
invite: publicInvite({ ...invite, client_name: clientId ? clients.get(clientId)?.name : null }),
|
||||
url: inviteLink(req, kind, token),
|
||||
token,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:id/revoke', (req, res) => {
|
||||
const invite = invites.get(req.params.id);
|
||||
if (!invite) return res.status(404).json({ error: 'no such invite' });
|
||||
invites.revoke(invite.id);
|
||||
audit(req.username, 'invite.revoke', invite.id, null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const invite = invites.get(req.params.id);
|
||||
if (!invite) return res.status(404).json({ error: 'no such invite' });
|
||||
invites.remove(invite.id);
|
||||
audit(req.username, 'invite.delete', invite.id, null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = { router, baseUrl, publicInvite };
|
||||
Reference in New Issue
Block a user