'use strict'; const fs = require('fs'); const http = require('http'); const path = require('path'); const express = require('express'); const { WebSocketServer } = require('ws'); const config = require('./config'); const { clients, sessions, audit } = require('./db'); const { sha256, timingSafeEqualHex } = require('./crypto'); const auth = require('./auth'); const tickets = require('./tickets'); const hub = require('./vnc/hub'); const bridge = require('./vnc/bridge'); const clientsRoutes = require('./routes/clients'); const invitesRoutes = require('./routes/invites'); const sessionsRoutes = require('./routes/sessions'); const publicRoutes = require('./routes/public'); const PUBLIC_DIR = path.join(__dirname, '..', 'public'); const NOVNC_DIR = path.join(__dirname, '..', 'node_modules', '@novnc', 'novnc'); const app = express(); if (config.trustProxy) app.set('trust proxy', true); app.use(express.json({ limit: '256kb' })); /* ------------------------------------------------------------------ auth */ app.post('/api/login', async (req, res) => { const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'username and password are required' }); try { const result = await auth.login(String(username), String(password)); audit(result.username, 'login', null, { ip: req.ip }); res.json({ token: result.token, username: result.username, isAdmin: auth.isAdmin(result), }); } catch (err) { res.status(err.status || 500).json({ error: err.message }); } }); app.get('/api/me', auth.requireAuth, (req, res) => { res.json({ username: req.username, isAdmin: req.isAdmin }); }); app.get('/api/health', (_req, res) => { res.json({ ok: true, agentsOnline: hub.onlineIds().length, liveSessions: bridge.listLive().length }); }); /* ---------------------------------------------------------------- routes */ app.use('/api/clients', clientsRoutes.router); app.use('/api/invites', invitesRoutes.router); app.use('/api/sessions', sessionsRoutes.router); app.use('/api/public', publicRoutes.router); /* ----------------------------------------------------------------- pages */ // Served unauthenticated on purpose: the enrolment page tells a machine to curl // this, and the agent is useless without a valid enrolment token anyway. app.get('/download/agent.js', (_req, res) => { res.type('application/javascript'); res.sendFile(path.join(__dirname, '..', 'agent', 'agent.js')); }); // Windows Server 2008 R2 (and Windows 7) cannot install any Node the agent // would run on — Node 14 dropped them. `pnpm build:agent-exe` bundles the same // agent.js with a Node 12 runtime into one file that needs nothing installed. // Built in CI, so a dev checkout will not have it; say so rather than 404. const AGENT_EXE = path.join(__dirname, '..', 'dist', 'rcs-agent.exe'); app.get('/download/agent.exe', (_req, res) => { if (!fs.existsSync(AGENT_EXE)) { return res.status(503).type('text/plain') .send('the Windows agent executable was not built into this deployment (pnpm build:agent-exe)'); } res.download(AGENT_EXE, 'rcs-agent.exe'); }); /* ------------------------------------------------------ install scripts */ // One-command registration: the hub stamps its own address and the enrolment // token into the script before serving it, so the whole thing is a single // copy-paste with nothing to fill in. // // curl -fsSL https://host/install.sh?token=TOKEN | sh // irm https://host/install.ps1?token=TOKEN | iex const SCRIPTS_DIR = path.join(__dirname, '..', 'scripts'); // Tokens are base64url (see randomToken). This output is piped straight into a // shell, so anything that is not shaped like a token is refused rather than // interpolated — a token is the only untrusted value in these files. const TOKEN_PATTERN = /^[A-Za-z0-9_-]{16,128}$/; function serveInstallScript(file, contentType) { return (req, res) => { const token = String(req.query.token || ''); if (token && !TOKEN_PATTERN.test(token)) { return res.status(400).type('text/plain').send('that is not a valid enrolment token'); } let body; try { body = fs.readFileSync(path.join(SCRIPTS_DIR, file), 'utf8'); } catch { return res.status(500).type('text/plain').send('install script missing from this deployment'); } body = body.split('__HUB__').join(config.baseUrl(req)).split('__TOKEN__').join(token); res.type(contentType).send(body); }; } app.get('/install.sh', serveInstallScript('install.sh', 'text/x-shellscript; charset=utf-8')); app.get('/install.ps1', serveInstallScript('install.ps1', 'text/plain; charset=utf-8')); app.use('/novnc', express.static(NOVNC_DIR, { maxAge: '7d', immutable: true })); app.use(express.static(PUBLIC_DIR)); app.get('/viewer', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'viewer.html'))); app.get('/docs', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'docs.html'))); app.get('/enroll/:token', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'enroll.html'))); app.get('/s/:token', (_req, res) => res.sendFile(path.join(PUBLIC_DIR, 'share.html'))); app.use((req, res) => { if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'not found' }); res.sendFile(path.join(PUBLIC_DIR, 'index.html')); }); // eslint-disable-next-line no-unused-vars -- Express identifies error handlers by arity app.use((err, req, res, _next) => { // A body express.json() could not parse is the caller's fault, not ours. if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'malformed JSON body' }); if (err.type === 'entity.too.large') return res.status(413).json({ error: 'request body too large' }); console.error('[http]', err); res.status(500).json({ error: 'internal error' }); }); /* ------------------------------------------------------------ websockets */ const server = http.createServer(app); const vncWss = new WebSocketServer({ noServer: true }); const agentWss = new WebSocketServer({ noServer: true }); const tunnelWss = new WebSocketServer({ noServer: true }); function clientIp(req) { if (config.trustProxy) { const fwd = req.headers['x-forwarded-for']; if (fwd) return String(fwd).split(',')[0].trim(); } return req.socket.remoteAddress; } /** Agent sockets authenticate with the key issued at enrolment, compared by hash. */ function authenticateAgent(params) { const clientId = params.get('clientId'); const key = params.get('key'); if (!clientId || !key) return null; const client = clients.get(clientId); if (!client || !client.agent_key_hash) return null; if (!timingSafeEqualHex(sha256(key), client.agent_key_hash)) return null; return client; } function reject(socket, code, message) { socket.write(`HTTP/1.1 ${code} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); socket.destroy(); } server.on('upgrade', (req, socket, head) => { let url; try { url = new URL(req.url, 'http://localhost'); } catch { return reject(socket, 400, 'Bad Request'); } const params = url.searchParams; const ip = clientIp(req); if (url.pathname === '/ws/vnc') { const payload = tickets.redeem(params.get('ticket')); if (!payload) return reject(socket, 401, 'Unauthorized'); const client = clients.get(payload.clientId); if (!client) return reject(socket, 404, 'Not Found'); return vncWss.handleUpgrade(req, socket, head, (ws) => { ws.binaryType = 'nodebuffer'; bridge.startSession(ws, { sessionId: payload.sessionId, client, username: payload.username, role: payload.role, source: payload.source, inviteId: payload.inviteId, remoteIp: ip, }).catch((err) => { console.error('[vnc] session failed', err); try { ws.close(4500, String(err.message).slice(0, 120)); } catch { /* gone */ } }); }); } if (url.pathname === '/ws/agent') { const client = authenticateAgent(params); if (!client) return reject(socket, 401, 'Unauthorized'); return agentWss.handleUpgrade(req, socket, head, (ws) => { hub.handleAgentSocket(ws, client, ip); }); } if (url.pathname === '/ws/tunnel') { const client = authenticateAgent(params); const tunnelId = params.get('tunnelId'); if (!client || !tunnelId) return reject(socket, 401, 'Unauthorized'); return tunnelWss.handleUpgrade(req, socket, head, (ws) => { ws.binaryType = 'nodebuffer'; hub.handleTunnelSocket(ws, client.id, tunnelId); }); } reject(socket, 404, 'Not Found'); }); /* ------------------------------------------------------------------ boot */ const orphans = sessions.closeOrphans(); if (orphans) console.log(`[boot] closed ${orphans} session(s) left open by a previous run`); server.listen(config.port, config.host, () => { console.log(`[boot] remote-control-support listening on http://${config.host}:${config.port}`); console.log(`[boot] auth service: ${config.authUrl}`); if (!config.adminUsers.length && config.adminLevel === null) { console.warn('[boot] no ADMIN_USERS or ADMIN_LEVEL set — every authenticated user is an admin'); } }); function shutdown(signal) { console.log(`[boot] ${signal} received, shutting down`); server.close(() => process.exit(0)); setTimeout(() => process.exit(0), 5000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); module.exports = { app, server };