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