Files
remote-control-support-webapp/server/routes/invites.js
T
rmancinasandClaude Opus 5 999717f77b 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>
2026-08-11 23:37:35 -07:00

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 };