Files
BetterDesk/web-nodejs/services/bdRelay.js
T
UNITRONIX e556b181df Gate agent admin UI by OS privileges
Add OS-level admin detection and use it to gate sensitive agent UI and tray actions. Introduce privileges.rs (Windows TokenElevation / Unix geteuid) and expose is_os_admin as a Tauri command; wire it into tray setup to hide admin-only menu items (Settings, Quit) and re-check privileges before executing those actions. Add show_window helper and emit navigate events from the tray; frontend listens for navigate and conditionally renders /settings (shows AdminRequired component for non-admins). Update App.tsx to query is_os_admin on startup and include navigation listener; add AdminRequired component, styles, and i18n keys. Update Cargo.toml with platform deps (windows features + libc for unix). Also add UI/locale assets and CSS for agent lazy-loaded device tabs and a notifications dropdown, plus several web-nodejs route/view/style updates and new task docs describing phase work.
2026-04-18 01:18:57 +02:00

540 lines
17 KiB
JavaScript

/**
* BetterDesk Console — Native WebSocket Relay
*
* Provides a direct relay channel between two BetterDesk desktop clients.
* Unlike the RustDesk WS proxy (wsRelay.js) which bridges browser→hbbr TCP,
* this relay pairs two desktop clients directly and passes E2E-encrypted
* binary frames between them. The server NEVER decrypts the payload.
*
* Protocol:
* 1. Client A → POST /api/bd/connect/:targetId
* → returns { sessionId, token }
* 2. Both clients → WS /ws/bd-relay?session=<id>&token=<token>&role=initiator|target
* 3. Server pairs them once both sides are connected
* 4. Binary frames are forwarded 1:1 between the pair
* 5. Either side closes → session torn down
*
* Security:
* - Tokens are single-use, short-lived (60 s default)
* - Rate-limited per IP (MAX_RELAY_PER_IP)
* - Binary-only after handshake (text frames rejected)
* - Maximum frame size enforced (MAX_FRAME_BYTES)
* - Session auto-cleanup every 30 s
*/
'use strict';
const WebSocket = require('ws');
const crypto = require('crypto');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const MAX_RELAY_PER_IP = 10; // concurrent relay WS per IP
const MAX_FRAME_BYTES = 2 * 1024 * 1024; // 2 MB per frame
const SESSION_TTL_MS = 60 * 1000; // token valid for 60 s after creation
const IDLE_TIMEOUT_MS = 120 * 1000; // close if no data for 2 min
const CLEANUP_INTERVAL = 30 * 1000; // cleanup tick
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
// sessionId → { initiatorWs, targetWs, initiatorToken, targetToken, createdAt, status }
const activeSessions = new Map();
// deviceId → WebSocket (for online device tracking / push notifications)
const onlineDevices = new Map();
// IP → count
const connectionsPerIp = new Map();
// requestId → { resolve, reject, timeout } for request/response pattern over signal WS
const pendingRequests = new Map();
const REQUEST_TIMEOUT_MS = 15 * 1000; // 15s default
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function generateToken() {
return crypto.randomBytes(32).toString('hex');
}
function clientIp(req) {
return req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|| req.socket?.remoteAddress
|| 'unknown';
}
function incIpCount(ip) {
connectionsPerIp.set(ip, (connectionsPerIp.get(ip) || 0) + 1);
}
function decIpCount(ip) {
const c = (connectionsPerIp.get(ip) || 1) - 1;
if (c <= 0) connectionsPerIp.delete(ip);
else connectionsPerIp.set(ip, c);
}
// ---------------------------------------------------------------------------
// Session management (called by bd-api routes)
// ---------------------------------------------------------------------------
/**
* Create a new relay session. Returns tokens for both participants.
* @param {string} initiatorId - device ID of the caller
* @param {string} targetId - device ID of the target
* @returns {{ sessionId: string, initiatorToken: string, targetToken: string }}
*/
function createRelaySession(initiatorId, targetId) {
const sessionId = crypto.randomUUID();
const initiatorToken = generateToken();
const targetToken = generateToken();
activeSessions.set(sessionId, {
initiatorId,
targetId,
initiatorWs: null,
targetWs: null,
initiatorToken,
targetToken,
createdAt: Date.now(),
status: 'pending', // pending → active → closed
});
return { sessionId, initiatorToken, targetToken };
}
/**
* Get session metadata (without tokens) for API responses.
*/
function getRelaySession(sessionId) {
const s = activeSessions.get(sessionId);
if (!s) return null;
return {
sessionId,
initiatorId: s.initiatorId,
targetId: s.targetId,
status: s.status,
createdAt: s.createdAt,
};
}
/**
* Notify a target device that someone wants to connect.
* Sends a JSON control message over the device's signaling WS.
*/
function notifyTarget(targetId, payload) {
const ws = onlineDevices.get(targetId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
return true;
}
return false;
}
/**
* Check if a device is currently online (has an active signaling WS).
*/
function isDeviceOnline(deviceId) {
const ws = onlineDevices.get(deviceId);
return ws && ws.readyState === WebSocket.OPEN;
}
/**
* Get list of currently online device IDs.
*/
function getOnlineDeviceIds() {
const ids = [];
for (const [id, ws] of onlineDevices) {
if (ws.readyState === WebSocket.OPEN) ids.push(id);
}
return ids;
}
/**
* Send a request to an online agent and await its response over the signal WS.
* The agent receives `{ type, request_id, payload }` and must reply with
* `{ type: 'command_response', request_id, ok, data?, error? }`.
*
* @param {string} deviceId
* @param {string} type e.g. 'services.list', 'files.browse'
* @param {object} payload optional type-specific payload
* @param {number} [timeoutMs=15000]
* @returns {Promise<any>} resolves with agent's `data`, rejects on error/timeout
*/
function requestFromDevice(deviceId, type, payload = null, timeoutMs = REQUEST_TIMEOUT_MS) {
return new Promise((resolve, reject) => {
const ws = onlineDevices.get(deviceId);
if (!ws || ws.readyState !== WebSocket.OPEN) {
return reject(new Error('agent_offline'));
}
const requestId = crypto.randomUUID();
const timeout = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error('agent_timeout'));
}, Math.max(1000, Math.min(60000, timeoutMs)));
pendingRequests.set(requestId, { resolve, reject, timeout });
try {
ws.send(JSON.stringify({ type, request_id: requestId, payload }));
} catch (err) {
pendingRequests.delete(requestId);
clearTimeout(timeout);
reject(err);
}
});
}
// ---------------------------------------------------------------------------
// WebSocket server initialisation
// ---------------------------------------------------------------------------
/**
* Initialize the BetterDesk relay WebSocket endpoints:
* /ws/bd-relay — data relay between paired clients
* /ws/bd-signal — signaling / presence channel per device
*
* @param {http.Server} server
*/
function initBdRelay(server) {
const relayWss = new WebSocket.Server({ noServer: true, maxPayload: MAX_FRAME_BYTES });
const signalWss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024 });
// 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;
if (pathname === '/ws/bd-relay') {
relayWss.handleUpgrade(request, socket, head, (ws) => {
relayWss.emit('connection', ws, request);
});
} else if (pathname === '/ws/bd-signal') {
signalWss.handleUpgrade(request, socket, head, (ws) => {
signalWss.emit('connection', ws, request);
});
}
// Other paths: do nothing — let other upgrade handlers deal with them
});
// ---- Relay connections ----
relayWss.on('connection', (ws, req) => {
handleRelayConnection(ws, req);
});
// ---- Signal connections ----
signalWss.on('connection', (ws, req) => {
handleSignalConnection(ws, req);
});
// Cleanup interval
const cleanupTimer = setInterval(cleanupSessions, CLEANUP_INTERVAL);
cleanupTimer.unref();
console.log(' BetterDesk relay: /ws/bd-relay (data relay)');
console.log(' BetterDesk relay: /ws/bd-signal (signaling)');
return { relayWss, signalWss };
}
// ---------------------------------------------------------------------------
// Relay connection handler
// ---------------------------------------------------------------------------
function handleRelayConnection(ws, req) {
const ip = clientIp(req);
const url = new URL(req.url, `http://${req.headers.host}`);
const sessionId = url.searchParams.get('session');
const token = url.searchParams.get('token');
const role = url.searchParams.get('role'); // 'initiator' | 'target'
// Validate parameters
if (!sessionId || !token || !role || !['initiator', 'target'].includes(role)) {
ws.close(4400, 'Missing or invalid parameters');
return;
}
// IP rate limit
if ((connectionsPerIp.get(ip) || 0) >= MAX_RELAY_PER_IP) {
ws.close(4429, 'Too many connections');
return;
}
const session = activeSessions.get(sessionId);
if (!session) {
ws.close(4404, 'Session not found');
return;
}
// Validate token
const expectedToken = role === 'initiator' ? session.initiatorToken : session.targetToken;
if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
ws.close(4403, 'Invalid token');
return;
}
// Check session age
if (Date.now() - session.createdAt > SESSION_TTL_MS && session.status === 'pending') {
activeSessions.delete(sessionId);
ws.close(4408, 'Session expired');
return;
}
// Attach
incIpCount(ip);
const slotKey = role === 'initiator' ? 'initiatorWs' : 'targetWs';
if (session[slotKey]) {
ws.close(4409, 'Slot already taken');
decIpCount(ip);
return;
}
session[slotKey] = ws;
// If both sides connected → activate
if (session.initiatorWs && session.targetWs) {
activateRelay(sessionId, session);
}
// Idle timeout for waiting side
let idleTimer = setTimeout(() => {
if (session.status === 'pending') {
teardownSession(sessionId, 4408, 'Timeout waiting for peer');
}
}, SESSION_TTL_MS);
ws.on('close', () => {
clearTimeout(idleTimer);
decIpCount(ip);
teardownSession(sessionId, 1000, 'Peer disconnected');
});
ws.on('error', () => {
clearTimeout(idleTimer);
decIpCount(ip);
teardownSession(sessionId, 1011, 'WebSocket error');
});
}
function activateRelay(sessionId, session) {
session.status = 'active';
const wsA = session.initiatorWs;
const wsB = session.targetWs;
// Send paired notification (JSON text frame)
const pairMsg = JSON.stringify({ type: 'paired', session: sessionId });
if (wsA.readyState === WebSocket.OPEN) wsA.send(pairMsg);
if (wsB.readyState === WebSocket.OPEN) wsB.send(pairMsg);
// Data relay (binary only after pairing)
let idleTimer = null;
const resetIdle = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
teardownSession(sessionId, 4408, 'Idle timeout');
}, IDLE_TIMEOUT_MS);
};
resetIdle();
wsA.on('message', (data, isBinary) => {
resetIdle();
if (wsB.readyState === WebSocket.OPEN) {
wsB.send(data, { binary: isBinary });
}
});
wsB.on('message', (data, isBinary) => {
resetIdle();
if (wsA.readyState === WebSocket.OPEN) {
wsA.send(data, { binary: isBinary });
}
});
const origClose = () => {
clearTimeout(idleTimer);
};
wsA.on('close', origClose);
wsB.on('close', origClose);
}
function teardownSession(sessionId, code, reason) {
const session = activeSessions.get(sessionId);
if (!session) return;
session.status = 'closed';
activeSessions.delete(sessionId);
for (const key of ['initiatorWs', 'targetWs']) {
const ws = session[key];
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
try { ws.close(code, reason); } catch (_) {}
}
}
}
// ---------------------------------------------------------------------------
// Signal connection handler (presence / push)
// ---------------------------------------------------------------------------
function handleSignalConnection(ws, req) {
const ip = clientIp(req);
const url = new URL(req.url, `http://${req.headers.host}`);
const deviceId = url.searchParams.get('device_id');
const token = url.searchParams.get('token');
if (!deviceId || !token) {
ws.close(4400, 'Missing device_id or token');
return;
}
// IP rate limit
if ((connectionsPerIp.get(ip) || 0) >= MAX_RELAY_PER_IP) {
ws.close(4429, 'Too many connections');
return;
}
incIpCount(ip);
// Store reference for push notifications
// If device already has a signal WS, close the old one
const existing = onlineDevices.get(deviceId);
if (existing && existing.readyState === WebSocket.OPEN) {
existing.close(4409, 'Replaced by new connection');
}
onlineDevices.set(deviceId, ws);
// Heartbeat
let heartbeatTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
}
}, 30000);
ws.on('message', (data) => {
// Signal messages are JSON
try {
const msg = JSON.parse(data.toString());
handleSignalMessage(deviceId, ws, msg);
} catch (_) {
// Ignore malformed messages
}
});
ws.on('close', () => {
clearInterval(heartbeatTimer);
decIpCount(ip);
if (onlineDevices.get(deviceId) === ws) {
onlineDevices.delete(deviceId);
}
});
ws.on('error', () => {
clearInterval(heartbeatTimer);
decIpCount(ip);
if (onlineDevices.get(deviceId) === ws) {
onlineDevices.delete(deviceId);
}
});
// Acknowledge connection
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'welcome', device_id: deviceId }));
}
}
function handleSignalMessage(deviceId, ws, msg) {
switch (msg.type) {
case 'heartbeat':
ws.send(JSON.stringify({ type: 'heartbeat_ack', ts: Date.now() }));
break;
case 'relay_accept': {
// Target device accepts incoming connection
const session = activeSessions.get(msg.session_id);
if (session && session.targetId === deviceId && session.status === 'pending') {
// Send target token to the target device so it can connect to the relay WS
ws.send(JSON.stringify({
type: 'relay_ready',
session_id: msg.session_id,
token: session.targetToken,
}));
}
break;
}
case 'relay_reject': {
// Target device rejects incoming connection
const session = activeSessions.get(msg.session_id);
if (session && session.targetId === deviceId) {
teardownSession(msg.session_id, 4403, 'Connection rejected by target');
}
break;
}
case 'command_response': {
// Agent responding to a requestFromDevice() call.
// msg = { type, request_id, ok, data?, error? }
const pending = pendingRequests.get(msg.request_id);
if (pending) {
pendingRequests.delete(msg.request_id);
clearTimeout(pending.timeout);
if (msg.ok) pending.resolve(msg.data || null);
else pending.reject(new Error(msg.error || 'agent_error'));
}
break;
}
default:
break;
}
}
// ---------------------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------------------
function cleanupSessions() {
const now = Date.now();
for (const [id, session] of activeSessions) {
// Expired pending sessions
if (session.status === 'pending' && now - session.createdAt > SESSION_TTL_MS * 2) {
teardownSession(id, 4408, 'Session expired');
}
}
// Clean dead device entries
for (const [id, ws] of onlineDevices) {
if (ws.readyState !== WebSocket.OPEN) {
onlineDevices.delete(id);
}
}
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
initBdRelay,
createRelaySession,
getRelaySession,
notifyTarget,
isDeviceOnline,
getOnlineDeviceIds,
requestFromDevice,
activeSessions, // exposed for monitoring/admin API
onlineDevices, // exposed for status sync
};