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