'use strict'; 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')); }); 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('/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) => { 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 };