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>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,136 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,172 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,78 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const { clients, sessions, audit } = require('../db');
|
||||
const { uuid } = require('../crypto');
|
||||
const { requireAuth, requireAdmin, roleForClient } = require('../auth');
|
||||
const tickets = require('../tickets');
|
||||
const bridge = require('../vnc/bridge');
|
||||
const hub = require('../vnc/hub');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
/** Mint a one-time ticket for the VNC WebSocket. This is the connect handshake. */
|
||||
router.post('/', (req, res) => {
|
||||
const client = clients.get(String((req.body || {}).clientId || ''));
|
||||
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' });
|
||||
|
||||
// A viewer can deliberately drop to view-only, but never upgrade past its grant.
|
||||
const requested = (req.body || {}).viewOnly ? 'viewer' : role;
|
||||
|
||||
if (client.mode === 'agent' && !hub.isOnline(client.id)) {
|
||||
return res.status(409).json({ error: 'that machine is offline' });
|
||||
}
|
||||
|
||||
const sessionId = uuid();
|
||||
const ticket = tickets.issue({
|
||||
sessionId,
|
||||
clientId: client.id,
|
||||
username: req.username,
|
||||
role: requested,
|
||||
source: 'web',
|
||||
});
|
||||
|
||||
res.json({
|
||||
ticket: ticket.token,
|
||||
expiresIn: ticket.expiresIn,
|
||||
sessionId,
|
||||
role: requested,
|
||||
clientName: client.name,
|
||||
requireConsent: !!client.require_consent,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/live', (req, res) => {
|
||||
const all = bridge.listLive();
|
||||
res.json({ sessions: req.isAdmin ? all : all.filter((s) => s.username === req.username) });
|
||||
});
|
||||
|
||||
router.get('/history', (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit) || 100, 500);
|
||||
const clientId = req.query.clientId ? String(req.query.clientId) : null;
|
||||
|
||||
if (!req.isAdmin) {
|
||||
if (!clientId) return res.status(403).json({ error: 'admin only' });
|
||||
if (!roleForClient(req.user, clientId, false)) {
|
||||
return res.status(403).json({ error: 'you do not have access to this client' });
|
||||
}
|
||||
}
|
||||
res.json({ sessions: sessions.recent(limit, clientId) });
|
||||
});
|
||||
|
||||
router.post('/:id/kill', requireAdmin, (req, res) => {
|
||||
const ok = bridge.killSession(req.params.id, `disconnected by ${req.username}`);
|
||||
if (!ok) return res.status(404).json({ error: 'no such live session' });
|
||||
audit(req.username, 'session.kill', req.params.id, null);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/audit', requireAdmin, (req, res) => {
|
||||
res.json({ audit: audit.recent(Math.min(Number(req.query.limit) || 200, 1000)) });
|
||||
});
|
||||
|
||||
module.exports = { router };
|
||||
Reference in New Issue
Block a user