Files
BetterDesk/web-nodejs/services/deviceStatusPush.js
T
UNITRONIX 8d3a5d4a69 fix(rbac): enforce persisted role permission overrides
Align the Node panel with Go role_permissions, protect user-directory paths and relays, and add regression coverage. Make the embedded worker test explicitly select its optional maintainer mode.

Refs #468

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow); muopc (@muopc)
2026-10-21 01:31:03 +02:00

287 lines
9.9 KiB
JavaScript

/**
* BetterDesk Console — Real-time Device Status Push
*
* Connects to Go server's WebSocket event bus and pushes device status
* changes to browser clients in real time.
*
* Go server endpoint: GET /api/ws/events
* Browser endpoints: WS /ws/device-status (device status)
* WS /ws/panel-events (registration notifications)
*/
'use strict';
const WebSocket = require('ws');
const db = require('./database');
const { roleHasEffectivePermission } = require('../middleware/auth');
const log = {
info: (...a) => console.log('[DeviceStatus]', ...a),
warn: (...a) => console.warn('[DeviceStatus]', ...a),
error: (...a) => console.error('[DeviceStatus]', ...a),
};
const PING_INTERVAL = 30000;
const RECONNECT_BASE = 3000;
const RECONNECT_MAX = 60000;
let panelEventBroadcaster = null;
/**
* Publish a local event to authenticated panel-event WebSocket clients.
* Registration routes use this for events created by Node.js itself (LAN
* discovery), while Go-originated events use the same browser channel below.
*/
function publishPanelEvent(data) {
if (typeof panelEventBroadcaster === 'function') {
panelEventBroadcaster(data);
}
}
/**
* Initialize real-time device status push.
* @param {import('http').Server} httpServer - The HTTP server to attach WS to
* @param {Function} sessionMiddleware - Express session middleware for auth
* @param {string} goApiUrl - Go server base URL (e.g. http://localhost:21121/api)
* @param {string} apiKey - API key for Go server authentication
*/
function initDeviceStatusPush(httpServer, sessionMiddleware, goApiUrl, apiKey) {
// Browser-facing WebSocket server
const wss = new WebSocket.Server({ noServer: true });
const clients = new Set();
const panelWss = new WebSocket.Server({ noServer: true });
const panelClients = new Set();
const { registerUpgradeHandler } = require('./wsUpgradeRouter');
async function authenticateUpgrade(req, socket, head, targetWss, authorize) {
sessionMiddleware(req, {}, () => {
if (!req.session || !req.session.userId) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
Promise.resolve(authorize ? authorize(req) : true)
.then((allowed) => {
if (!allowed) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
}
targetWss.handleUpgrade(req, socket, head, (ws) => {
targetWss.emit('connection', ws, req);
});
})
.catch(() => {
socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
socket.destroy();
});
});
}
// Handle upgrade requests via shared router (#295)
registerUpgradeHandler(
httpServer,
(pathname) => pathname === '/ws/device-status',
(req, socket, head) => authenticateUpgrade(req, socket, head, wss)
);
registerUpgradeHandler(
httpServer,
(pathname) => pathname === '/ws/panel-events',
(req, socket, head) => authenticateUpgrade(
req,
socket,
head,
panelWss,
(request) => roleHasEffectivePermission(
request.session?.user?.role,
'enrollment.approve'
)
)
);
wss.on('connection', (ws) => {
clients.add(ws);
// Ping to keep alive
const pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.ping();
else clearInterval(pingTimer);
}, PING_INTERVAL);
ws.on('close', () => {
clients.delete(ws);
clearInterval(pingTimer);
});
ws.on('error', () => {
clients.delete(ws);
clearInterval(pingTimer);
});
});
panelWss.on('connection', (ws) => {
panelClients.add(ws);
const pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.ping();
else clearInterval(pingTimer);
}, PING_INTERVAL);
ws.on('close', () => {
panelClients.delete(ws);
clearInterval(pingTimer);
});
ws.on('error', () => {
panelClients.delete(ws);
clearInterval(pingTimer);
});
});
// Broadcast to all connected browser clients
function broadcast(data) {
const text = JSON.stringify(data);
for (const ws of clients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(text);
}
}
}
function broadcastPanelEvent(data) {
const text = JSON.stringify(data);
for (const ws of panelClients) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(text);
}
}
}
panelEventBroadcaster = broadcastPanelEvent;
// Connect to Go server event bus
let retryDelay = RECONNECT_BASE;
function connectToGoEventBus() {
const wsUrl = goApiUrl
.replace(/^http:/, 'ws:')
.replace(/^https:/, 'wss:')
.replace(/\/api$/, '');
const url = `${wsUrl}/api/ws/events?api_key=${encodeURIComponent(apiKey)}`;
log.info('Connecting to Go event bus...');
// Only set TLS options when connecting via wss://
const wsOpts = { headers: { 'X-API-Key': apiKey } };
if (wsUrl.startsWith('wss://')) {
wsOpts.rejectUnauthorized = !require('../config/config').allowSelfSignedCerts;
}
const goWs = new WebSocket(url, wsOpts);
goWs.on('open', () => {
log.info('Connected to Go event bus');
retryDelay = RECONNECT_BASE;
});
goWs.on('message', (data) => {
try {
const event = JSON.parse(data.toString());
const payload = event.data || {};
if (event.type === 'peer_id_changed') {
const oldId = payload.old_id;
const newId = payload.new_id;
if (oldId && newId && typeof db.cascadePeerIdChange === 'function') {
Promise.resolve(db.cascadePeerIdChange(oldId, newId)).catch((err) => {
log.warn('Panel ID cascade failed:', err.message || err);
});
}
broadcast({
type: 'device_id_changed',
old_id: oldId,
new_id: newId,
source: payload.source || '',
timestamp: event.timestamp || Date.now(),
});
return;
}
if (event.type === 'device_pending' || event.type === 'enrollment_pending') {
broadcastPanelEvent({
type: 'registration_pending',
registration: payload,
timestamp: event.timestamp || Date.now(),
});
return;
}
if (event.type === 'device_approved' || event.type === 'device_rejected' ||
event.type === 'enrollment_rejection_cleared') {
broadcastPanelEvent({
type: 'registration_changed',
registration: payload,
timestamp: event.timestamp || Date.now(),
});
return;
}
if (event.type === 'help_request') {
broadcastPanelEvent({
type: 'help_request',
...payload,
timestamp: event.timestamp || Date.now(),
});
return;
}
// Forward peer status events to browser clients
if (event.type === 'peer_online' || event.type === 'peer_offline' ||
event.type === 'peer_status_changed' || event.type === 'peer_registered') {
broadcast({
type: 'device_status',
device_id: payload.peer_id || payload.id || event.peer_id || event.id || event.device_id,
status: payload.status || event.status || (event.type === 'peer_online' ? 'online' : 'offline'),
timestamp: event.timestamp || Date.now(),
details: event,
});
}
} catch (_) {
// Ignore unparseable frames
}
});
goWs.on('close', () => {
log.warn('Go event bus disconnected, retrying in ' + retryDelay + 'ms');
setTimeout(connectToGoEventBus, retryDelay);
retryDelay = Math.min(retryDelay * 2, RECONNECT_MAX);
});
goWs.on('error', (err) => {
const msg = err.message || '';
// Detect TLS mismatch (Go server has TLS_API=Y but we connect via ws://) — issue #104
if (msg.includes('unexpected server response: 400') || msg.includes('Parse Error') ||
msg.includes('ECONNRESET') || msg.includes('socket hang up')) {
log.error('Go event bus error: ' + msg +
' — If Go server has TLS_API=Y enabled, the API port must stay HTTP. See issue #104.');
} else {
log.error('Go event bus error:', msg);
}
goWs.close();
});
}
// Only connect if we have the Go API URL
if (goApiUrl && apiKey) {
connectToGoEventBus();
} else {
log.warn('Go API URL or API key not configured, device status push disabled');
}
log.info('Device status push initialized');
return wss;
}
module.exports = { initDeviceStatusPush, publishPanelEvent };