Files
BetterDesk/web-nodejs/services/deviceStatusPush.js
T
UNITRONIX 0f161181f1 feat(installers): run services under unprivileged accounts by default
Privilege separation across all installers so the long-running services no longer run with full administrative rights:

betterdesk.sh: installer keeps root but systemd units now run as a dedicated unprivileged 'betterdesk' system account by default (auto-created via ensure_service_user). Added full systemd hardening for the Go server (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, ReadWritePaths) and light hardening for the Node.js console. chown migrates existing root-owned data to the service account on update. Opt-out via --run-as-root / BETTERDESK_RUN_AS_ROOT=1; custom account via BETTERDESK_SERVICE_USER. Minimal mode covered too.

betterdesk.ps1: NSSM services now run under their per-service low-privilege virtual accounts (NT SERVICE\<service>) instead of LocalSystem, with scoped icacls grants on the install/data dirs (Set-ServiceLeastPrivilege helper). Applied to the Go server, Node.js console and minimal-mode service. Opt-out via -RunAsRoot / BETTERDESK_RUN_AS_ROOT=1.

Docker: verified already privilege-separated (supervisord drops both programs to user=betterdesk; multi-container images drop via su-exec).

Also bundles in-progress changes to the Go server API, Node.js console services and Docker compose/Dockerfiles.

This commit was made possible thanks to Insolve.
2026-05-31 00:35:28 +02:00

162 lines
5.4 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?filter=peer_online
* Browser endpoint: WS /ws/device-status
*/
'use strict';
const WebSocket = require('ws');
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;
/**
* 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();
// 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);
});
});
});
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);
});
});
// 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);
}
}
}
// 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?filter=peer_online&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());
// 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: event.peer_id || event.id || event.device_id,
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 };