Files
remote-control-support-webapp/server/index.js
T
rmancinasandClaude Opus 5 88be2bf867
Build and Deploy Remote Control Support / Test (push) Successful in 11s
Build and Deploy Remote Control Support / Build Image (push) Successful in 47s
Build and Deploy Remote Control Support / Deploy to Portainer (push) Successful in 7s
fix(http): 400 on malformed JSON body instead of 500
express.json() raising a parse error was falling through to the generic
500 handler, which reads as a server fault for what is a bad request.
Also maps the body-size limit to 413.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:55:48 -07:00

199 lines
7.1 KiB
JavaScript

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