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>
173 lines
5.7 KiB
JavaScript
173 lines
5.7 KiB
JavaScript
'use strict';
|
|
|
|
// Unauthenticated endpoints backing the two invite link types. Everything here
|
|
// is reachable without a login, so each handler is rate limited and every token
|
|
// is looked up by hash.
|
|
|
|
const express = require('express');
|
|
const config = require('../config');
|
|
const { invites, inviteUsable, clients, audit } = require('../db');
|
|
const { uuid, randomToken, sha256 } = require('../crypto');
|
|
const tickets = require('../tickets');
|
|
|
|
const router = express.Router();
|
|
|
|
/* Crude per-IP limiter: enough to make token guessing pointless without pulling
|
|
in a dependency. Buckets refill continuously and are swept on a timer. */
|
|
const buckets = new Map();
|
|
const LIMIT = 30;
|
|
const WINDOW_MS = 60_000;
|
|
|
|
function rateLimit(req, res, next) {
|
|
const ip = req.ip || 'unknown';
|
|
const now = Date.now();
|
|
const b = buckets.get(ip) || { count: 0, resetAt: now + WINDOW_MS };
|
|
if (b.resetAt < now) {
|
|
b.count = 0;
|
|
b.resetAt = now + WINDOW_MS;
|
|
}
|
|
b.count++;
|
|
buckets.set(ip, b);
|
|
if (b.count > LIMIT) {
|
|
return res.status(429).json({ error: 'too many attempts, wait a minute' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, b] of buckets) if (b.resetAt < now) buckets.delete(ip);
|
|
}, WINDOW_MS).unref?.();
|
|
|
|
router.use(rateLimit);
|
|
|
|
function lookup(token) {
|
|
const invite = invites.findByHash(sha256(String(token || '')));
|
|
const usable = inviteUsable(invite);
|
|
return { invite, usable };
|
|
}
|
|
|
|
/** What a landing page needs to render, without consuming a use. */
|
|
router.get('/invite/:token', (req, res) => {
|
|
const { invite, usable } = lookup(req.params.token);
|
|
if (!invite) return res.status(404).json({ error: 'this link is not valid' });
|
|
|
|
const client = invite.client_id ? clients.get(invite.client_id) : null;
|
|
res.json({
|
|
kind: invite.kind,
|
|
label: invite.label,
|
|
role: invite.role,
|
|
clientName: client ? client.name : null,
|
|
expiresAt: invite.expires_at,
|
|
usable: usable.ok,
|
|
reason: usable.ok ? null : usable.reason,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A machine redeems an enrollment link. Returns an agent key, shown once and
|
|
* stored only as a hash — the agent keeps it and reconnects with it forever.
|
|
*/
|
|
router.post('/enroll', (req, res) => {
|
|
const body = req.body || {};
|
|
const { invite, usable } = lookup(body.token);
|
|
if (!invite || invite.kind !== 'enroll') return res.status(404).json({ error: 'this enrollment link is not valid' });
|
|
if (!usable.ok) return res.status(410).json({ error: `this enrollment link is ${usable.reason}` });
|
|
|
|
const prefill = invite.prefill ? JSON.parse(invite.prefill) : {};
|
|
const hostname = String(body.hostname || '').trim().slice(0, 100) || 'unnamed machine';
|
|
const agentKey = randomToken(32);
|
|
|
|
let client;
|
|
if (invite.client_id) {
|
|
// Re-enrolment of an existing entry: keep its grants and history, new key.
|
|
client = clients.get(invite.client_id);
|
|
if (!client) return res.status(410).json({ error: 'the client this link pointed at has been deleted' });
|
|
clients.update(client.id, {
|
|
mode: 'agent',
|
|
agent_key_hash: sha256(agentKey),
|
|
hostname,
|
|
os: body.os ? String(body.os).slice(0, 60) : null,
|
|
agent_version: body.agentVersion ? String(body.agentVersion).slice(0, 30) : null,
|
|
port: Number(body.vncPort) || client.port || 5900,
|
|
enrolled_at: Date.now(),
|
|
last_ip: req.ip,
|
|
});
|
|
} else {
|
|
let name = (prefill.name || body.name || hostname).trim().slice(0, 100);
|
|
// Names are how operators pick a machine, so keep them unique.
|
|
if (clients.getByName(name)) {
|
|
let n = 2;
|
|
while (clients.getByName(`${name} (${n})`)) n++;
|
|
name = `${name} (${n})`;
|
|
}
|
|
client = clients.create({
|
|
id: uuid(),
|
|
name,
|
|
mode: 'agent',
|
|
host: null,
|
|
port: Number(body.vncPort) || 5900,
|
|
agent_key_hash: sha256(agentKey),
|
|
require_consent: prefill.requireConsent ? 1 : 0,
|
|
tags: prefill.tags || '',
|
|
os: body.os ? String(body.os).slice(0, 60) : null,
|
|
hostname,
|
|
agent_version: body.agentVersion ? String(body.agentVersion).slice(0, 30) : null,
|
|
enrolled_at: Date.now(),
|
|
created_by: invite.created_by,
|
|
});
|
|
}
|
|
|
|
invites.consume(invite.id);
|
|
audit(invite.created_by, 'client.enroll', client.id, { name: client.name, hostname, ip: req.ip });
|
|
|
|
res.status(201).json({
|
|
clientId: client.id,
|
|
name: client.name,
|
|
agentKey,
|
|
requireConsent: !!client.require_consent,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A person redeems a support link. No login: the invite itself is the
|
|
* authorisation, and it fixes both the target machine and the role.
|
|
*/
|
|
router.post('/session/:token', (req, res) => {
|
|
if (!config.allowSessionInvites) return res.status(403).json({ error: 'session invites are disabled' });
|
|
|
|
const { invite, usable } = lookup(req.params.token);
|
|
if (!invite || invite.kind !== 'session') return res.status(404).json({ error: 'this link is not valid' });
|
|
if (!usable.ok) return res.status(410).json({ error: `this link is ${usable.reason}` });
|
|
|
|
const client = clients.get(invite.client_id);
|
|
if (!client) return res.status(410).json({ error: 'the machine this link pointed at has been removed' });
|
|
|
|
const displayName = String((req.body || {}).name || '').trim().slice(0, 60);
|
|
const username = displayName ? `invite:${displayName}` : `invite:${invite.label || invite.id.slice(0, 8)}`;
|
|
const role = invite.role === 'operator' ? 'operator' : 'viewer';
|
|
const sessionId = uuid();
|
|
|
|
const ticket = tickets.issue({
|
|
sessionId,
|
|
clientId: client.id,
|
|
username,
|
|
role,
|
|
source: 'invite',
|
|
inviteId: invite.id,
|
|
});
|
|
|
|
invites.consume(invite.id);
|
|
audit(username, 'invite.redeem', invite.id, { clientId: client.id, role, ip: req.ip });
|
|
|
|
res.json({
|
|
ticket: ticket.token,
|
|
expiresIn: ticket.expiresIn,
|
|
clientName: client.name,
|
|
role,
|
|
sessionId,
|
|
});
|
|
});
|
|
|
|
module.exports = { router };
|