fix(console): share one WebSocket upgrade dispatcher (#295)

Collapse 11 per-service upgrade listeners into wsUpgradeRouter so Node no longer emits a false MaxListenersExceededWarning at panel startup.
This commit is contained in:
UNITRONIX
2026-07-22 18:21:18 +02:00
parent 840a58ca1d
commit ce988724c1
13 changed files with 535 additions and 318 deletions
+3
View File
@@ -1,5 +1,8 @@
## [Unreleased]
### Fixed
- **False MaxListenersExceededWarning on panel WebSocket upgrades (#295):** console WebSocket services now share a single HTTP `upgrade` dispatcher instead of stacking 11 separate listeners (Node's default max is 10). Reconnects never added listeners — the warning was a startup false positive, not a reconnect leak. Session `MemoryStore` remains intentional for the single-process console (shared store is for future multi-instance HA only).
### Changed
- _(none yet)_
+6
View File
@@ -107,12 +107,18 @@ app.use(cookieParser());
// Session management — also kept as a standalone middleware ref for WebSocket upgrades
// Use a different cookie name in HTTP mode to avoid collision with stale
// Secure cookies left over from a previous HTTPS configuration (Issue #82).
//
// MemoryStore is intentional for the single-process console (GitHub #295).
// express-session warns in production that MemoryStore is not for multi-process
// or HA; BetterDesk runs one Node panel per host. Shared store (PostgreSQL/Redis)
// is planned only for multi-instance HA — see docs/enterprise/IMPLEMENTATION_PLAN.md.
const SESSION_COOKIE = config.httpsEnabled ? 'betterdesk.sid' : 'bd.sid';
const sessionMiddleware = session({
secret: config.sessionSecret,
name: SESSION_COOKIE,
resave: false,
saveUninitialized: false,
store: new session.MemoryStore(),
cookie: {
secure: config.httpsEnabled,
httpOnly: true,
+20 -17
View File
@@ -208,26 +208,29 @@ function initBdRelay(server) {
const relayWss = new WebSocket.Server({ noServer: true, maxPayload: MAX_FRAME_BYTES });
const signalWss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024 });
const { enforceOrigin } = require('../middleware/wsOrigin');
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
// Attach to server upgrade — only handle /ws/bd-relay and /ws/bd-signal,
// let other handlers (remoteRelay, chatRelay, cdap) handle their paths.
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const pathname = url.pathname;
// Shared upgrade router — paths /ws/bd-relay and /ws/bd-signal (#295).
registerUpgradeHandler(
server,
(pathname) => pathname === '/ws/bd-relay' || pathname === '/ws/bd-signal',
(request, socket, head) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const pathname = url.pathname;
if (pathname === '/ws/bd-relay') {
if (!enforceOrigin(request, socket, `bd-relay ${pathname}`)) return;
relayWss.handleUpgrade(request, socket, head, (ws) => {
relayWss.emit('connection', ws, request);
});
} else if (pathname === '/ws/bd-signal') {
if (!enforceOrigin(request, socket, `bd-signal ${pathname}`)) return;
signalWss.handleUpgrade(request, socket, head, (ws) => {
signalWss.emit('connection', ws, request);
});
if (pathname === '/ws/bd-relay') {
if (!enforceOrigin(request, socket, `bd-relay ${pathname}`)) return;
relayWss.handleUpgrade(request, socket, head, (ws) => {
relayWss.emit('connection', ws, request);
});
} else if (pathname === '/ws/bd-signal') {
if (!enforceOrigin(request, socket, `bd-signal ${pathname}`)) return;
signalWss.handleUpgrade(request, socket, head, (ws) => {
signalWss.emit('connection', ws, request);
});
}
}
// Other paths: do nothing — let other upgrade handlers deal with them
});
);
// ---- Relay connections ----
+40 -35
View File
@@ -41,51 +41,56 @@ function createCdapMediaProxy(server, sessionMiddleware, opts) {
const wss = new WebSocket.Server({ noServer: true });
const { enforceOrigin } = require('../middleware/wsOrigin');
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const match = url.pathname.match(pattern);
if (!match) return;
registerUpgradeHandler(
server,
(pathname) => pattern.test(pathname),
(req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const match = url.pathname.match(pattern);
if (!match) return;
const deviceId = match[1];
const deviceId = match[1];
// CSWSH protection — reject before validating session.
if (!enforceOrigin(req, socket, `cdap-${label} ${url.pathname}`)) return;
// CSWSH protection — reject before validating session.
if (!enforceOrigin(req, socket, `cdap-${label} ${url.pathname}`)) return;
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
console.warn(`[CDAP ${label}] 401 upgrade rejected for ${url.pathname} (no session; ip=${req.socket?.remoteAddress})`);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
console.warn(`[CDAP ${label}] 401 upgrade rejected for ${url.pathname} (no session; ip=${req.socket?.remoteAddress})`);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Session stores the user under req.session.user; older code paths
// also write flat fields. Accept either shape.
const sessUser = req.session.user || {};
const userRole = sessUser.role || req.session.role || '';
const userName = sessUser.username || req.session.username || `user#${req.session.userId}`;
// Session stores the user under req.session.user; older code paths
// also write flat fields. Accept either shape.
const sessUser = req.session.user || {};
const userRole = sessUser.role || req.session.role || '';
const userName = sessUser.username || req.session.username || `user#${req.session.userId}`;
const userLevel = roleLevel[userRole] || 0;
const requiredLevel = roleLevel[minRole] || 3;
if (userLevel < requiredLevel) {
console.warn(`[CDAP ${label}] 403 upgrade rejected for ${url.pathname} (user=${userName} role=${userRole} level=${userLevel} < required=${requiredLevel})`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
const userLevel = roleLevel[userRole] || 0;
const requiredLevel = roleLevel[minRole] || 3;
if (userLevel < requiredLevel) {
console.warn(`[CDAP ${label}] 403 upgrade rejected for ${url.pathname} (user=${userName} role=${userRole} level=${userLevel} < required=${requiredLevel})`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
console.log(`[CDAP ${label}] Upgrade accepted for device=${deviceId} user=${userName} role=${userRole}`);
console.log(`[CDAP ${label}] Upgrade accepted for device=${deviceId} user=${userName} role=${userRole}`);
// Attach normalized fields so the connection handler can use them.
req._cdapUserName = userName;
req._cdapUserRole = userRole;
// Attach normalized fields so the connection handler can use them.
req._cdapUserName = userName;
req._cdapUserRole = userRole;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, deviceId);
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, deviceId);
});
});
});
});
}
);
wss.on('connection', (browserWs, req, deviceId) => {
const username = req._cdapUserName || req.session?.user?.username || req.session?.username || 'admin';
+39 -34
View File
@@ -18,50 +18,55 @@ const config = require('../config/config');
function initCdapTerminalProxy(server, sessionMiddleware) {
const wss = new WebSocket.Server({ noServer: true });
const { enforceOrigin } = require('../middleware/wsOrigin');
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
registerUpgradeHandler(
server,
(pathname) => /^\/api\/cdap\/devices\/[A-Za-z0-9_-]{6,30}\/terminal$/.test(pathname),
(req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
// Match /api/cdap/devices/:id/terminal
const match = pathname.match(/^\/api\/cdap\/devices\/([A-Za-z0-9_-]{6,30})\/terminal$/);
if (!match) return; // Let other upgrade handlers deal with it
// Match /api/cdap/devices/:id/terminal
const match = pathname.match(/^\/api\/cdap\/devices\/([A-Za-z0-9_-]{6,30})\/terminal$/);
if (!match) return;
const deviceId = match[1];
const deviceId = match[1];
// CSWSH protection — reject before validating session.
if (!enforceOrigin(req, socket, `cdap-terminal ${pathname}`)) return;
// CSWSH protection — reject before validating session.
if (!enforceOrigin(req, socket, `cdap-terminal ${pathname}`)) return;
// Require session authentication
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Require session authentication
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Session may store user under req.session.user (object) or as
// flat fields. Accept either; treat super_admin/admin as admin.
const sessUser = req.session.user || {};
const userRole = sessUser.role || req.session.role || '';
const userName = sessUser.username || req.session.username || `user#${req.session.userId}`;
// Session may store user under req.session.user (object) or as
// flat fields. Accept either; treat super_admin/admin as admin.
const sessUser = req.session.user || {};
const userRole = sessUser.role || req.session.role || '';
const userName = sessUser.username || req.session.username || `user#${req.session.userId}`;
// RBAC: only admin / super_admin users can access terminal
if (userRole !== 'admin' && userRole !== 'super_admin') {
console.warn(`[CDAP Terminal] 403 upgrade rejected (user=${userName} role=${userRole})`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
// RBAC: only admin / super_admin users can access terminal
if (userRole !== 'admin' && userRole !== 'super_admin') {
console.warn(`[CDAP Terminal] 403 upgrade rejected (user=${userName} role=${userRole})`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
req._cdapUserName = userName;
req._cdapUserRole = userRole;
req._cdapUserName = userName;
req._cdapUserRole = userRole;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, deviceId);
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, deviceId);
});
});
});
});
}
);
wss.on('connection', (browserWs, req, deviceId) => {
const username = req._cdapUserName || req.session?.user?.username || 'admin';
+29 -25
View File
@@ -496,36 +496,40 @@ function initChatRelay(server, sessionMiddleware, betterdeskApi) {
const wss = new WebSocket.Server({ noServer: true });
const { enforceOrigin } = require('../middleware/wsOrigin');
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
registerUpgradeHandler(
server,
(pathname) => /^\/ws\/chat\/[^/]+$/.test(pathname) || /^\/ws\/chat-operator\/[^/]+$/.test(pathname),
(req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
const agentMatch = pathname.match(/^\/ws\/chat\/([^/]+)$/);
if (agentMatch) {
if (!enforceOrigin(req, socket, `chat-agent ${pathname}`)) return;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, 'agent', agentMatch[1]);
});
return;
}
const opMatch = pathname.match(/^\/ws\/chat-operator\/([^/]+)$/);
if (opMatch) {
if (!enforceOrigin(req, socket, `chat-operator ${pathname}`)) return;
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
const agentMatch = pathname.match(/^\/ws\/chat\/([^/]+)$/);
if (agentMatch) {
if (!enforceOrigin(req, socket, `chat-agent ${pathname}`)) return;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, 'operator', opMatch[1]);
wss.emit('connection', ws, req, 'agent', agentMatch[1]);
});
});
return;
return;
}
const opMatch = pathname.match(/^\/ws\/chat-operator\/([^/]+)$/);
if (opMatch) {
if (!enforceOrigin(req, socket, `chat-operator ${pathname}`)) return;
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req, 'operator', opMatch[1]);
});
});
}
}
});
);
wss.on('connection', (ws, req, role, deviceId) => {
if (role === 'agent') {
+18 -16
View File
@@ -34,24 +34,26 @@ function initDeviceStatusPush(httpServer, sessionMiddleware, goApiUrl, apiKey) {
// Browser-facing WebSocket server
const wss = new WebSocket.Server({ noServer: true });
const clients = new Set();
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
// Handle upgrade requests
httpServer.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== '/ws/device-status') return;
// Authenticate via session
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
// Handle upgrade requests via shared router (#295)
registerUpgradeHandler(
httpServer,
(pathname) => pathname === '/ws/device-status',
(req, socket, head) => {
// Authenticate via session
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
});
});
});
}
);
wss.on('connection', (ws) => {
clients.add(ws);
+48 -44
View File
@@ -6,6 +6,7 @@
const WebSocket = require('ws');
const config = require('../config/config');
const { enforceOrigin } = require('../middleware/wsOrigin');
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
const MESH_PATHS = new Set([
'/agent.ashx',
@@ -21,60 +22,63 @@ function goWsBase() {
function initMeshAshxProxy(server, sessionMiddleware) {
const wss = new WebSocket.Server({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (!MESH_PATHS.has(url.pathname)) return;
registerUpgradeHandler(
server,
(pathname) => MESH_PATHS.has(pathname),
(req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const needsSession = url.pathname === '/control.ashx';
const label = `mesh-${url.pathname}`;
const needsSession = url.pathname === '/control.ashx';
const label = `mesh-${url.pathname}`;
if (!enforceOrigin(req, socket, label)) return;
if (!enforceOrigin(req, socket, label)) return;
const connect = () => {
wss.handleUpgrade(req, socket, head, (browserWs) => {
const target = goWsBase() + url.pathname + (url.search || '');
const goWs = new WebSocket(target, {
headers: {
'x-forwarded-for': req.socket?.remoteAddress || '',
},
});
goWs.on('open', () => {
browserWs.on('message', (data, isBinary) => {
if (goWs.readyState === WebSocket.OPEN) {
goWs.send(data, { binary: isBinary });
}
const connect = () => {
wss.handleUpgrade(req, socket, head, (browserWs) => {
const target = goWsBase() + url.pathname + (url.search || '');
const goWs = new WebSocket(target, {
headers: {
'x-forwarded-for': req.socket?.remoteAddress || '',
},
});
goWs.on('message', (data, isBinary) => {
if (browserWs.readyState === WebSocket.OPEN) {
browserWs.send(data, { binary: isBinary });
}
goWs.on('open', () => {
browserWs.on('message', (data, isBinary) => {
if (goWs.readyState === WebSocket.OPEN) {
goWs.send(data, { binary: isBinary });
}
});
goWs.on('message', (data, isBinary) => {
if (browserWs.readyState === WebSocket.OPEN) {
browserWs.send(data, { binary: isBinary });
}
});
});
});
goWs.on('error', (err) => {
console.warn('[mesh proxy]', url.pathname, err.message);
browserWs.close();
goWs.on('error', (err) => {
console.warn('[mesh proxy]', url.pathname, err.message);
browserWs.close();
});
browserWs.on('close', () => goWs.close());
goWs.on('close', () => browserWs.close());
browserWs.on('error', () => goWs.close());
});
browserWs.on('close', () => goWs.close());
goWs.on('close', () => browserWs.close());
browserWs.on('error', () => goWs.close());
});
};
};
if (needsSession) {
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (needsSession) {
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
connect();
});
} else {
connect();
});
} else {
connect();
}
}
});
);
}
module.exports = { initMeshAshxProxy };
+50 -46
View File
@@ -283,59 +283,63 @@ function handleViewerConnection(ws, deviceId, operatorName) {
function initRemoteRelay(server, sessionMiddleware) {
const wss = new WebSocket.Server({ noServer: true });
const { enforceOrigin } = require('../middleware/wsOrigin');
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const path = url.pathname;
registerUpgradeHandler(
server,
(path) => /^\/ws\/remote-agent\/[^/]+$/.test(path) || /^\/ws\/remote-viewer\/[^/]+$/.test(path),
(req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const path = url.pathname;
// Agent: /ws/remote-agent/<device_id>
const agentMatch = path.match(/^\/ws\/remote-agent\/([^/]+)$/);
if (agentMatch) {
if (!enforceOrigin(req, socket, `remote-agent ${path}`)) return;
const deviceId = decodeURIComponent(agentMatch[1]);
const token = url.searchParams.get('token') || '';
// Validate device ID format (reject path traversal etc.)
if (!/^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
// Agent: /ws/remote-agent/<device_id>
const agentMatch = path.match(/^\/ws\/remote-agent\/([^/]+)$/);
if (agentMatch) {
if (!enforceOrigin(req, socket, `remote-agent ${path}`)) return;
const deviceId = decodeURIComponent(agentMatch[1]);
const token = url.searchParams.get('token') || '';
// Validate device ID format (reject path traversal etc.)
if (!/^[A-Za-z0-9_-]{3,64}$/.test(deviceId)) {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
verifyAgentConnection(deviceId, token).then((ok) => {
if (!ok) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
handleAgentConnection(ws, deviceId);
});
}).catch(() => {
try {
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
socket.destroy();
} catch (_) { /* closed */ }
});
return;
}
verifyAgentConnection(deviceId, token).then((ok) => {
if (!ok) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
handleAgentConnection(ws, deviceId);
});
}).catch(() => {
try {
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
socket.destroy();
} catch (_) { /* closed */ }
});
return;
}
// Viewer (operator): /ws/remote-viewer/<device_id>
const viewerMatch = path.match(/^\/ws\/remote-viewer\/([^/]+)$/);
if (viewerMatch) {
if (!enforceOrigin(req, socket, `remote-viewer ${path}`)) return;
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
const opName = req.session.username || 'operator';
handleViewerConnection(ws, viewerMatch[1], opName);
// Viewer (operator): /ws/remote-viewer/<device_id>
const viewerMatch = path.match(/^\/ws\/remote-viewer\/([^/]+)$/);
if (viewerMatch) {
if (!enforceOrigin(req, socket, `remote-viewer ${path}`)) return;
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
const opName = req.session.username || 'operator';
handleViewerConnection(ws, viewerMatch[1], opName);
});
});
});
return;
}
}
});
);
log.info('Remote relay initialized (/ws/remote-agent/:id, /ws/remote-viewer/:id)');
return wss;
+27 -25
View File
@@ -233,32 +233,34 @@ function startShell(cols, rows, userInfo) {
function initServerTerminalProxy(server, sessionMiddleware, opts) {
const wss = new WebSocket.Server({ noServer: true });
const audit = opts && typeof opts.logAction === 'function' ? opts.logAction : null;
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== '/ws/server-management/terminal') return;
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
return socket.destroy();
}
const sessUser = req.session.user || {};
const role = sessUser.role || req.session.role || '';
const username = sessUser.username || `user#${req.session.userId}`;
// RBAC: only super_admin / admin / server_admin
if (!(role === 'super_admin' || role === 'admin' || role === 'server_admin')) {
console.warn(`[srv-term] 403 upgrade rejected (user=${username} role=${role})`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
return socket.destroy();
}
req._smUserName = username;
req._smUserRole = role;
req._smUserId = req.session.userId;
req._smIp = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});
});
registerUpgradeHandler(
server,
(pathname) => pathname === '/ws/server-management/terminal',
(req, socket, head) => {
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
return socket.destroy();
}
const sessUser = req.session.user || {};
const role = sessUser.role || req.session.role || '';
const username = sessUser.username || `user#${req.session.userId}`;
// RBAC: only super_admin / admin / server_admin
if (!(role === 'super_admin' || role === 'admin' || role === 'server_admin')) {
console.warn(`[srv-term] 403 upgrade rejected (user=${username} role=${role})`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
return socket.destroy();
}
req._smUserName = username;
req._smUserRole = role;
req._smUserId = req.session.userId;
req._smIp = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});
}
);
wss.on('connection', (ws, req) => {
const sessionId = makeSessionId();
+75 -76
View File
@@ -16,6 +16,7 @@ const net = require('net');
const os = require('os');
const config = require('../config/config');
const { enforceOrigin } = require('../middleware/wsOrigin');
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
// Maximum concurrent relay connections per IP
const MAX_CONNECTIONS_PER_IP = 5;
@@ -65,88 +66,86 @@ function initWsProxy(server, sessionMiddleware) {
// Relay proxy (hbbr)
const relayWss = new WebSocket.Server({ noServer: true });
// Handle upgrade requests — verify session cookie before allowing WebSocket
// Only handles /ws/rendezvous and /ws/relay; other paths are left for
// downstream handlers (chatRelay, remoteRelay, cdapProxy, etc.)
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const pathname = url.pathname;
// Handle upgrade requests — verify session cookie before allowing WebSocket.
// Paths owned: /ws/rendezvous, /ws/relay (shared upgrade router — #295).
registerUpgradeHandler(
server,
(pathname) => pathname === '/ws/rendezvous' || pathname === '/ws/relay',
(request, socket, head) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const pathname = url.pathname;
// Only handle paths this proxy owns
if (pathname !== '/ws/rendezvous' && pathname !== '/ws/relay') {
return; // let other upgrade handlers deal with it
}
// CSWSH protection: reject cross-origin upgrades before touching session
if (!enforceOrigin(request, socket, `ws-proxy ${pathname}`)) return;
// CSWSH protection: reject cross-origin upgrades before touching session
if (!enforceOrigin(request, socket, `ws-proxy ${pathname}`)) return;
// Validate the session against the real Express session store.
// Using sessionMiddleware (from server.js) populates req.session, which
// we then check for an authenticated userId. This replaces the old
// cookie-name-only check that could be bypassed with a fake cookie.
if (typeof sessionMiddleware !== 'function') {
console.warn('WS proxy: sessionMiddleware not provided — rejecting upgrade');
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
socket.destroy();
return;
}
// Attach a minimal fake response so session middleware can call next()
const fakeRes = Object.create(null);
fakeRes.getHeader = () => undefined;
fakeRes.setHeader = () => {};
fakeRes.end = () => {};
fakeRes.on = () => {};
sessionMiddleware(request, fakeRes, () => {
const hasUser = request.session && request.session.userId;
let hasGuest = false;
if (!hasUser) {
try {
// Prefer ?guest= on WS URL (session pages always append it for guests)
const g = url.searchParams.get('guest') || url.searchParams.get('t');
if (g) {
hasGuest = true;
request.guestToken = g;
}
if (!hasGuest) {
const { GUEST_COOKIE } = require('../middleware/guestAccess');
const raw = request.headers.cookie || '';
const names = [GUEST_COOKIE, 'bd.guest', 'betterdesk.guest'];
for (const cookieName of names) {
const match = raw.split(';').map((p) => p.trim()).find((p) => p.startsWith(cookieName + '='));
if (match) {
const val = decodeURIComponent(match.slice(cookieName.length + 1) || '');
if (val) {
hasGuest = true;
request.guestToken = val;
break;
}
}
}
}
} catch {
hasGuest = false;
}
}
if (!hasUser && !hasGuest) {
console.warn(`WS proxy: Rejected upgrade to ${pathname} — no authenticated session (ip: ${request.socket?.remoteAddress})`);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
// Validate the session against the real Express session store.
// Using sessionMiddleware (from server.js) populates req.session, which
// we then check for an authenticated userId. This replaces the old
// cookie-name-only check that could be bypassed with a fake cookie.
if (typeof sessionMiddleware !== 'function') {
console.warn('WS proxy: sessionMiddleware not provided — rejecting upgrade');
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
socket.destroy();
return;
}
if (pathname === '/ws/rendezvous') {
rendezvousWss.handleUpgrade(request, socket, head, (ws) => {
rendezvousWss.emit('connection', ws, request);
});
} else {
relayWss.handleUpgrade(request, socket, head, (ws) => {
relayWss.emit('connection', ws, request);
});
}
});
}); // server.on('upgrade')
// Attach a minimal fake response so session middleware can call next()
const fakeRes = Object.create(null);
fakeRes.getHeader = () => undefined;
fakeRes.setHeader = () => {};
fakeRes.end = () => {};
fakeRes.on = () => {};
sessionMiddleware(request, fakeRes, () => {
const hasUser = request.session && request.session.userId;
let hasGuest = false;
if (!hasUser) {
try {
// Prefer ?guest= on WS URL (session pages always append it for guests)
const g = url.searchParams.get('guest') || url.searchParams.get('t');
if (g) {
hasGuest = true;
request.guestToken = g;
}
if (!hasGuest) {
const { GUEST_COOKIE } = require('../middleware/guestAccess');
const raw = request.headers.cookie || '';
const names = [GUEST_COOKIE, 'bd.guest', 'betterdesk.guest'];
for (const cookieName of names) {
const match = raw.split(';').map((p) => p.trim()).find((p) => p.startsWith(cookieName + '='));
if (match) {
const val = decodeURIComponent(match.slice(cookieName.length + 1) || '');
if (val) {
hasGuest = true;
request.guestToken = val;
break;
}
}
}
}
} catch {
hasGuest = false;
}
}
if (!hasUser && !hasGuest) {
console.warn(`WS proxy: Rejected upgrade to ${pathname} — no authenticated session (ip: ${request.socket?.remoteAddress})`);
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
if (pathname === '/ws/rendezvous') {
rendezvousWss.handleUpgrade(request, socket, head, (ws) => {
rendezvousWss.emit('connection', ws, request);
});
} else {
relayWss.handleUpgrade(request, socket, head, (ws) => {
relayWss.emit('connection', ws, request);
});
}
});
}
);
// Parse target host/port from config
const hbbsHost = config.wsProxy?.hbbsHost || 'localhost';
+85
View File
@@ -0,0 +1,85 @@
/**
* Shared WebSocket upgrade router for the BetterDesk console HTTP server.
*
* Previously each WS service called server.on('upgrade'), which stacked 11
* listeners and triggered Node's MaxListenersExceededWarning (default max=10).
* Reconnects never added listeners — the warning was a false-positive leak
* signal (GitHub #295). This module attaches exactly one upgrade listener per
* server and dispatches by pathname.
*/
'use strict';
/** @type {WeakMap<object, { routes: Array<{ match: Function, handle: Function }> }>} */
const routers = new WeakMap();
/**
* Ensure a single upgrade dispatcher is attached to the given server.
* @param {import('http').Server|import('events').EventEmitter} server
*/
function ensureRouter(server) {
let state = routers.get(server);
if (state) return state;
state = { routes: [] };
routers.set(server, state);
server.on('upgrade', (req, socket, head) => {
let pathname;
try {
pathname = new URL(req.url, `http://${req.headers.host || 'localhost'}`).pathname;
} catch {
try {
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
} catch (_) { /* closed */ }
try { socket.destroy(); } catch (_) { /* closed */ }
return;
}
for (const route of state.routes) {
if (route.match(pathname, req)) {
route.handle(req, socket, head);
return;
}
}
// No registered route — leave the socket alone (same as the old
// multi-listener chain when every handler early-returned).
});
return state;
}
/**
* Register a WebSocket upgrade handler on the shared per-server router.
* Idempotent with respect to the HTTP listener: only one server.on('upgrade')
* is ever attached per server instance.
*
* @param {import('http').Server|import('events').EventEmitter} server
* @param {(pathname: string, req: import('http').IncomingMessage) => boolean} match
* @param {(req: import('http').IncomingMessage, socket: import('net').Socket, head: Buffer) => void} handle
*/
function registerUpgradeHandler(server, match, handle) {
if (!server || typeof server.on !== 'function') {
throw new TypeError('registerUpgradeHandler: server must be an EventEmitter');
}
if (typeof match !== 'function' || typeof handle !== 'function') {
throw new TypeError('registerUpgradeHandler: match and handle must be functions');
}
const state = ensureRouter(server);
state.routes.push({ match, handle });
}
/**
* Number of routes registered for this server (for tests / diagnostics).
* @param {object} server
* @returns {number}
*/
function registeredRouteCount(server) {
const state = routers.get(server);
return state ? state.routes.length : 0;
}
module.exports = {
registerUpgradeHandler,
registeredRouteCount,
};
+95
View File
@@ -0,0 +1,95 @@
/**
* Shared WS upgrade router — prevents MaxListenersExceededWarning (#295).
*
* All panel WebSocket services must register via registerUpgradeHandler so the
* HTTP server has exactly one 'upgrade' listener regardless of how many routes
* are registered.
*/
'use strict';
const http = require('http');
const { EventEmitter } = require('events');
describe('wsUpgradeRouter', () => {
beforeEach(() => {
jest.resetModules();
});
test('attaches exactly one upgrade listener for many route registrations', () => {
const { registerUpgradeHandler, registeredRouteCount } = require('../services/wsUpgradeRouter');
const server = new EventEmitter();
for (let i = 0; i < 11; i += 1) {
const path = `/ws/route-${i}`;
registerUpgradeHandler(server, (pathname) => pathname === path, () => {});
}
expect(server.listenerCount('upgrade')).toBe(1);
expect(registeredRouteCount(server)).toBe(11);
});
test('dispatches to the matching route only', () => {
const { registerUpgradeHandler } = require('../services/wsUpgradeRouter');
const server = new EventEmitter();
const hits = [];
registerUpgradeHandler(server, (p) => p === '/ws/a', () => { hits.push('a'); });
registerUpgradeHandler(server, (p) => p === '/ws/b', () => { hits.push('b'); });
const socket = { write: jest.fn(), destroy: jest.fn() };
server.emit('upgrade', { url: '/ws/b', headers: { host: 'localhost' } }, socket, Buffer.alloc(0));
expect(hits).toEqual(['b']);
expect(socket.write).not.toHaveBeenCalled();
});
test('leaves socket alone when no route matches', () => {
const { registerUpgradeHandler } = require('../services/wsUpgradeRouter');
const server = new EventEmitter();
registerUpgradeHandler(server, (p) => p === '/ws/owned', () => {});
const socket = { write: jest.fn(), destroy: jest.fn() };
server.emit('upgrade', { url: '/ws/other', headers: { host: 'localhost' } }, socket, Buffer.alloc(0));
expect(socket.write).not.toHaveBeenCalled();
expect(socket.destroy).not.toHaveBeenCalled();
});
test('production WS inits share a single upgrade listener on one HTTP server', () => {
const sessionStub = (req, _res, next) => {
req.session = { userId: 1 };
next();
};
const { initWsProxy } = require('../services/wsRelay');
const { initBdRelay } = require('../services/bdRelay');
const { initChatRelay } = require('../services/chatRelay');
const { initRemoteRelay } = require('../services/remoteRelay');
const { initCdapTerminalProxy } = require('../services/cdapTerminalProxy');
const { initCdapMediaProxies } = require('../services/cdapMediaProxy');
const { initMeshAshxProxy } = require('../services/meshAshxProxy');
const { registerUpgradeHandler, registeredRouteCount } = require('../services/wsUpgradeRouter');
const server = http.createServer((req, res) => {
res.writeHead(404);
res.end();
});
initWsProxy(server, sessionStub);
initBdRelay(server);
initChatRelay(server, sessionStub, null);
initRemoteRelay(server, sessionStub);
initCdapTerminalProxy(server, sessionStub);
initCdapMediaProxies(server, sessionStub);
initMeshAshxProxy(server, sessionStub);
// deviceStatusPush uses the same registerUpgradeHandler API; register its
// path without starting the Go event-bus reconnect loop (keeps Jest clean).
registerUpgradeHandler(server, (pathname) => pathname === '/ws/device-status', () => {});
// 1 wsRelay + 1 bdRelay + 1 chat + 1 remote + 1 cdap terminal
// + 4 cdap media + 1 mesh + 1 device-status = 11 routes, 1 listener
expect(server.listenerCount('upgrade')).toBe(1);
expect(registeredRouteCount(server)).toBe(11);
});
});