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>
137 lines
4.3 KiB
JavaScript
137 lines
4.3 KiB
JavaScript
'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 };
|