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>
198 lines
7.2 KiB
JavaScript
198 lines
7.2 KiB
JavaScript
'use strict';
|
|
|
|
const express = require('express');
|
|
const { clients, grants, audit } = require('../db');
|
|
const { encrypt, uuid, sha256, randomToken } = require('../crypto');
|
|
const { requireAuth, requireAdmin, roleForClient, visibleClients } = require('../auth');
|
|
const hub = require('../vnc/hub');
|
|
const bridge = require('../vnc/bridge');
|
|
|
|
const router = express.Router();
|
|
|
|
/** Shape a client row for the API. The stored VNC password never leaves the hub. */
|
|
function publicClient(c, extra = {}) {
|
|
return {
|
|
id: c.id,
|
|
name: c.name,
|
|
description: c.description,
|
|
mode: c.mode,
|
|
host: c.host,
|
|
port: c.port,
|
|
hasPassword: !!c.vnc_password_enc,
|
|
enrolled: !!c.agent_key_hash,
|
|
requireConsent: !!c.require_consent,
|
|
tags: c.tags ? c.tags.split(',').filter(Boolean) : [],
|
|
os: c.os,
|
|
hostname: c.hostname,
|
|
agentVersion: c.agent_version,
|
|
lastSeenAt: c.last_seen_at,
|
|
lastIp: c.last_ip,
|
|
createdBy: c.created_by,
|
|
createdAt: c.created_at,
|
|
online: c.mode === 'agent' ? hub.isOnline(c.id) : null,
|
|
grantedRole: c.granted_role,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function normalizeTags(tags) {
|
|
if (Array.isArray(tags)) return tags.map((t) => String(t).trim()).filter(Boolean).join(',');
|
|
if (typeof tags === 'string') return tags.split(',').map((t) => t.trim()).filter(Boolean).join(',');
|
|
return '';
|
|
}
|
|
|
|
function validate(body, { partial = false } = {}) {
|
|
const errors = [];
|
|
const out = {};
|
|
|
|
if (body.name !== undefined) {
|
|
const name = String(body.name).trim();
|
|
if (!name) errors.push('name is required');
|
|
else if (name.length > 100) errors.push('name is too long');
|
|
else out.name = name;
|
|
} else if (!partial) {
|
|
errors.push('name is required');
|
|
}
|
|
|
|
if (body.mode !== undefined) {
|
|
if (!['direct', 'agent'].includes(body.mode)) errors.push('mode must be "direct" or "agent"');
|
|
else out.mode = body.mode;
|
|
} else if (!partial) {
|
|
out.mode = 'direct';
|
|
}
|
|
|
|
if (body.host !== undefined) out.host = body.host ? String(body.host).trim() : null;
|
|
|
|
if (body.port !== undefined && body.port !== null && body.port !== '') {
|
|
const port = Number(body.port);
|
|
if (!Number.isInteger(port) || port < 1 || port > 65535) errors.push('port must be between 1 and 65535');
|
|
else out.port = port;
|
|
}
|
|
|
|
if (body.description !== undefined) out.description = body.description ? String(body.description) : null;
|
|
if (body.tags !== undefined) out.tags = normalizeTags(body.tags);
|
|
if (body.requireConsent !== undefined) out.require_consent = body.requireConsent ? 1 : 0;
|
|
|
|
// "" clears the stored password; undefined leaves it alone.
|
|
if (body.vncPassword !== undefined) {
|
|
out.vnc_password_enc = body.vncPassword ? encrypt(String(body.vncPassword)) : null;
|
|
}
|
|
|
|
const mode = out.mode || (partial ? undefined : 'direct');
|
|
if (mode === 'direct' && !partial && !out.host) errors.push('direct clients need a host');
|
|
|
|
return { value: out, errors };
|
|
}
|
|
|
|
router.use(requireAuth);
|
|
|
|
router.get('/', (req, res) => {
|
|
const rows = visibleClients(req.user, req.isAdmin);
|
|
res.json({ clients: rows.map((c) => publicClient(c)), isAdmin: req.isAdmin });
|
|
});
|
|
|
|
router.get('/:id', (req, res) => {
|
|
const client = clients.get(req.params.id);
|
|
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' });
|
|
|
|
res.json({
|
|
client: publicClient(client, { yourRole: role, agent: hub.agentInfo(client.id) }),
|
|
grants: req.isAdmin ? grants.listForClient(client.id) : undefined,
|
|
});
|
|
});
|
|
|
|
router.post('/', requireAdmin, (req, res) => {
|
|
const { value, errors } = validate(req.body || {});
|
|
if (errors.length) return res.status(400).json({ error: errors.join('; ') });
|
|
|
|
if (clients.getByName(value.name)) return res.status(409).json({ error: 'a client with that name already exists' });
|
|
|
|
const id = uuid();
|
|
const created = clients.create({ ...value, id, created_by: req.username });
|
|
audit(req.username, 'client.create', id, { name: created.name, mode: created.mode });
|
|
res.status(201).json({ client: publicClient(created) });
|
|
});
|
|
|
|
router.patch('/:id', requireAdmin, (req, res) => {
|
|
const client = clients.get(req.params.id);
|
|
if (!client) return res.status(404).json({ error: 'no such client' });
|
|
|
|
const { value, errors } = validate(req.body || {}, { partial: true });
|
|
if (errors.length) return res.status(400).json({ error: errors.join('; ') });
|
|
|
|
if (value.name && value.name !== client.name) {
|
|
const clash = clients.getByName(value.name);
|
|
if (clash && clash.id !== client.id) return res.status(409).json({ error: 'a client with that name already exists' });
|
|
}
|
|
|
|
const updated = clients.update(client.id, value);
|
|
audit(req.username, 'client.update', client.id, Object.keys(value));
|
|
res.json({ client: publicClient(updated) });
|
|
});
|
|
|
|
router.delete('/:id', requireAdmin, (req, res) => {
|
|
const client = clients.get(req.params.id);
|
|
if (!client) return res.status(404).json({ error: 'no such client' });
|
|
|
|
bridge.killSessionsForClient(client.id, 'client removed');
|
|
hub.disconnectAgent(client.id, 'client removed');
|
|
clients.remove(client.id);
|
|
audit(req.username, 'client.delete', client.id, { name: client.name });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/**
|
|
* Issue a fresh agent key. Returned exactly once — only its hash is stored — so
|
|
* the UI has to show it to the operator there and then.
|
|
*/
|
|
router.post('/:id/agent-key', requireAdmin, (req, res) => {
|
|
const client = clients.get(req.params.id);
|
|
if (!client) return res.status(404).json({ error: 'no such client' });
|
|
|
|
const agentKey = randomToken(32);
|
|
clients.update(client.id, { agent_key_hash: sha256(agentKey), mode: 'agent' });
|
|
hub.disconnectAgent(client.id, 'agent key rotated');
|
|
audit(req.username, 'client.agent-key', client.id, null);
|
|
res.json({ agentKey, clientId: client.id });
|
|
});
|
|
|
|
/* ---------------------------------------------------------------- grants */
|
|
|
|
router.get('/:id/grants', requireAdmin, (req, res) => {
|
|
if (!clients.get(req.params.id)) return res.status(404).json({ error: 'no such client' });
|
|
res.json({ grants: grants.listForClient(req.params.id) });
|
|
});
|
|
|
|
router.post('/:id/grants', requireAdmin, (req, res) => {
|
|
const client = clients.get(req.params.id);
|
|
if (!client) return res.status(404).json({ error: 'no such client' });
|
|
|
|
const username = String((req.body || {}).username || '').trim();
|
|
const role = (req.body || {}).role === 'operator' ? 'operator' : 'viewer';
|
|
const expiresAt = (req.body || {}).expiresAt ? Number((req.body || {}).expiresAt) : null;
|
|
if (!username) return res.status(400).json({ error: 'username is required' });
|
|
|
|
const grant = grants.upsert({
|
|
id: uuid(),
|
|
client_id: client.id,
|
|
username,
|
|
role,
|
|
expires_at: expiresAt,
|
|
created_by: req.username,
|
|
});
|
|
audit(req.username, 'grant.set', client.id, { username, role, expiresAt });
|
|
res.status(201).json({ grant });
|
|
});
|
|
|
|
router.delete('/:id/grants/:username', requireAdmin, (req, res) => {
|
|
if (!clients.get(req.params.id)) return res.status(404).json({ error: 'no such client' });
|
|
grants.remove(req.params.id, req.params.username);
|
|
audit(req.username, 'grant.remove', req.params.id, { username: req.params.username });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
module.exports = { router, publicClient };
|