Files
BetterDesk/web-nodejs/server.js
T
UNITRONIX 9e9230b467 docs: update CHANGELOG for Help panel overhaul and tutorial removal
The Help panel now replaces guided tours with a right-side panel displaying project supporters and GitHub sponsorship links. The previous tutorial features, including spotlight tutorials and the floating help FAB, have been removed. Additionally, updates to the sponsors update process and relevant documentation have been made to reflect these changes.
2026-08-01 23:58:15 +02:00

905 lines
40 KiB
JavaScript

/**
* BetterDesk Console - Server Entry Point
* Professional Web Management Panel for RustDesk Server
*
* @author UNITRONIX
* @version 2.1.0
* @license AGPL-3.0
*/
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
const path = require('path');
const fs = require('fs');
const http = require('http');
const https = require('https');
const config = require('./config/config');
const { redactUrlForLog } = require('./lib/logRedact');
const logger = require('./lib/logger');
const securityMiddleware = require('./middleware/security');
const { initI18n } = require('./middleware/i18n');
const { apiLimiter, widgetLimiter, panelPreferenceLimiter, getPanelPollMountPaths } = require('./middleware/rateLimiter');
const { csrfTokenProvider, doubleCsrfProtection, downgradeToHttp: csrfDowngradeToHttp } = require('./middleware/csrf');
const { roleHasPermission, isSuperAdminRole } = require('./middleware/auth');
const authService = require('./services/authService');
const serverBackend = require('./services/serverBackend');
const db = require('./services/database');
const userSync = require('./services/userSync');
const { initWsProxy } = require('./services/wsRelay');
const { initBdRelay } = require('./services/bdRelay');
const { initChatRelay } = require('./services/chatRelay');
const { apiClient: goApiClient } = require('./services/betterdeskApi');
const { initRemoteRelay } = require('./services/remoteRelay');
const { initCdapTerminalProxy } = require('./services/cdapTerminalProxy');
const { initCdapMediaProxies } = require('./services/cdapMediaProxy');
const { initMeshAshxProxy } = require('./services/meshAshxProxy');
const { startDiscoveryService } = require('./services/lanDiscovery');
const { initDeviceStatusPush } = require('./services/deviceStatusPush');
const { initHelpRequestEmailService } = require('./services/helpRequestEmailService');
const { loadSupporters } = require('./services/supportersService');
const routes = require('./routes');
const rustdeskApiRoutes = require('./routes/rustdesk-api.routes');
const bdApiRoutes = require('./routes/bd-api.routes');
const { getWanMiddlewareStack } = require('./middleware/wanSecurity');
const { parseTrustProxy } = require('./lib/parseTrustProxy');
const {
resolvePortForCurrentUser,
formatHttpsRedirectUrl,
attachPrivilegedPortErrorHandler,
} = require('./lib/privilegedPorts');
// Create Express app
const app = express();
// Trust proxy (for rate limiting behind reverse proxy)
// Configurable via TRUST_PROXY env var: false/0=off, 1/true=single proxy, 'loopback'=localhost only
// Default: false (safest). Set TRUST_PROXY=1 when behind nginx/Apache/cloudflare
const trustProxy = parseTrustProxy(process.env.TRUST_PROXY);
app.set('trust proxy', trustProxy);
// View engine setup
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Ensure data directory exists (required before auth.db / session secret)
try {
if (!fs.existsSync(config.dataDir)) {
fs.mkdirSync(config.dataDir, { recursive: true });
}
} catch (err) {
console.error(`Failed to create data directory (${config.dataDir}): ${err.message}`);
console.error('If the console runs as a dedicated user, run: sudo node scripts/linux-ensure-console-user.js');
process.exit(1);
}
// ============ Middleware Pipeline ============
// Security headers (Helmet)
app.use(securityMiddleware);
// CORS for BetterDesk desktop clients (Tauri webview origins)
app.use('/api/', (req, res, next) => {
const origin = req.headers.origin || '';
const allowed = [
'http://localhost:1420', // Tauri dev
'tauri://localhost', // Tauri production (macOS/Linux)
'https://tauri.localhost', // Tauri production (Windows)
];
if (allowed.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, X-CSRF-Token');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Max-Age', '86400');
}
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
// Body parsing (2MB limit for base64 logo images)
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: false, limit: '2mb' }));
// Cookie parsing
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,
sameSite: 'lax',
maxAge: config.sessionMaxAge
}
});
app.use(sessionMiddleware);
// Cache version — changes on every restart/deployment, stable during runtime.
// Used in ?v= query strings so browsers cache assets per deployment.
app.locals.cacheVersion = config.appVersion + '.' + Date.now();
// Static files
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: config.isProduction ? '7d' : '0',
etag: true
}));
// Serve proto files for remote client (protobufjs dynamic loading)
app.use('/protos', express.static(path.join(__dirname, 'protos'), {
maxAge: config.isProduction ? '7d' : '0',
etag: true
}));
// Serve uploaded branding assets (logos etc.) from persistent data dir
const uploadsDir = path.join(config.dataDir || path.join(__dirname, 'data'), 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
app.use('/uploads', express.static(uploadsDir, {
maxAge: config.isProduction ? '30d' : '0',
etag: true
}));
// Serve desktop wallpapers
app.use('/wallpapers', express.static(path.join(__dirname, 'wallpapers'), {
maxAge: config.isProduction ? '30d' : '0',
etag: true,
immutable: true
}));
// Rate limiting for API.
// SECURITY (audit fix M-03, 2026-04-10): high-frequency widget refresh paths
// have their own higher-quota limiter mounted BEFORE the general one so they
// are still bounded but do not eat into the regular API budget.
for (const p of getPanelPollMountPaths()) {
app.use(p, widgetLimiter);
}
app.use('/api/panel', widgetLimiter);
app.use('/api/desktop/layout', panelPreferenceLimiter);
app.use('/api/', apiLimiter);
// RustDesk Client API — mounted BEFORE CSRF because desktop clients use Bearer
// token auth, not cookie-based CSRF. These routes are also served on the
// dedicated WAN-facing port (21121) with additional hardening.
app.use(rustdeskApiRoutes);
// BetterDesk Desktop Client API — device-facing endpoints that use
// Bearer token or X-Device-Id header, not browser CSRF cookies.
app.use('/api/bd', bdApiRoutes);
// i18n middleware
app.use(initI18n());
// Embed mode — when ?embed=1 is present, layout renders without sidebar/navbar
// Used by Desktop Mode to load pages inside floating windows (iframes)
app.use((req, res, next) => {
res.locals.embed = req.query.embed === '1';
// UI shell: classic (rail+flyout, default) | ux35 (full-list sidebar)
// Cookie remembers last choice; ?ui=classic|ux35 overrides and persists.
const UI_SHELL_COOKIE = 'bd_ui_shell';
let uiShell = 'classic';
const q = String(req.query.ui || '').toLowerCase();
if (q === 'ux35' || q === 'classic') {
uiShell = q;
res.cookie(UI_SHELL_COOKIE, uiShell, {
maxAge: 365 * 24 * 60 * 60 * 1000,
sameSite: 'lax',
httpOnly: false,
path: '/'
});
} else {
const raw = String(req.cookies?.[UI_SHELL_COOKIE] || '').toLowerCase();
if (raw === 'ux35' || raw === 'classic') uiShell = raw;
}
res.locals.uiShell = uiShell;
res.locals.supporters = loadSupporters();
// Inject permission helper for EJS templates (sidebar/button visibility)
const role = req.session?.user?.role;
res.locals.hasPermission = (perm) => role ? roleHasPermission(role, perm) : false;
res.locals.isSuperAdmin = role ? isSuperAdminRole(role) : false;
// Prevent HTML page caching — only static assets should be cached
if (!req.path.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|proto)$/)) {
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
}
next();
});
// CSRF protection — generate token for views, validate on POST/PUT/DELETE/PATCH.
// Skip CSRF for device-facing API routes (/api/bd/*) — these MUST authenticate
// via Bearer access token (session-cookie fallback is rejected in requireDeviceAuth).
//
// SECURITY (audit fix C-02, 2026-04-10): the previous Origin-based CSRF skip
// for Tauri webview origins (`tauri://localhost`, `https://tauri.localhost`,
// `http://localhost:1420`) was removed — `Origin` is freely forgeable by any
// non-browser HTTP client, so it is unsafe as a CSRF-bypass signal. Tauri
// desktop clients receive the CSRF token via `csrfTokenProvider` and must
// echo it back in the `X-CSRF-Token` header (csrf-csrf double-submit).
app.use(csrfTokenProvider);
app.use((req, res, next) => {
if (req.path.startsWith('/api/bd/')) {
return next();
}
doubleCsrfProtection(req, res, next);
});
// ============ Routes ============
app.use('/', routes);
// ============ Error Handlers ============
// CSRF token mismatch
app.use((err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN' || err.message?.includes('csrf') || err.message?.includes('CSRF')) {
res.status(403);
// Detect likely SSL→HTTP transition: cookie missing because browser held Secure cookie
const likelySslTransition = !config.httpsEnabled && !req.secure;
const hint = likelySslTransition
? ' If you recently disabled SSL, clear your browser cookies for this site and reload.'
: '';
// Always return JSON for API routes (fetch sends Accept: */*)
if (req.path.startsWith('/api/') || (req.headers['content-type'] && req.headers['content-type'].includes('application/json'))) {
return res.json({ success: false, error: 'Invalid CSRF token. Please refresh the page and try again.' + hint });
}
if (req.accepts('html')) {
return res.render('errors/500', {
title: 'Forbidden',
activePage: 'error',
error: 'Invalid or missing CSRF token. Please refresh the page and try again.' + hint
});
}
return res.json({ success: false, error: 'Invalid CSRF token' + hint });
}
next(err);
});
// 404 Not Found
app.use((req, res, next) => {
res.status(404);
// Log unmatched /api/* and /ws/* paths only (avoid noise from missing
// static assets like favicons). Diagnostics suggestion credit:
// progloto (PR #81).
if (req.originalUrl.startsWith('/api/') || req.originalUrl.startsWith('/ws/')) {
const ip = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'unknown';
const ua = String(req.headers['user-agent'] || '').slice(0, 80);
console.warn(`[panel] 404 ${req.method} ${req.originalUrl} from ${ip} ua="${ua}"`);
}
if (req.accepts('html')) {
res.render('errors/404', {
title: req.t ? req.t('errors.not_found') : 'Not Found',
activePage: 'error'
});
} else {
res.json({
success: false,
error: 'Not Found'
});
}
});
// 500 Server Error
app.use((err, req, res, next) => {
logger.error('Server error:', err);
res.status(err.status || 500);
// Always return JSON for API routes
if (req.path.startsWith('/api/') || (req.headers['content-type'] && req.headers['content-type'].includes('application/json'))) {
return res.json({
success: false,
error: config.isProduction ? 'Internal Server Error' : err.message
});
}
if (req.accepts('html')) {
res.render('errors/500', {
title: req.t ? req.t('errors.server_error') : 'Server Error',
activePage: 'error',
error: config.isProduction ? null : err.message
});
} else {
res.json({
success: false,
error: config.isProduction ? 'Internal Server Error' : err.message
});
}
});
// ============ Startup ============
/**
* Warn if the user set Go-server-only TLS env vars in the Node.js environment.
* These variables (TLS_CERT, TLS_KEY) are read exclusively by the Go server.
* The Node.js console uses SSL_CERT_PATH / SSL_KEY_PATH instead.
* Silently ignoring them causes issue #104 — port 21121 stays HTTP while the
* RustDesk client expects HTTPS, producing InvalidContentType errors.
*/
function warnGoTlsEnvVars() {
const hasTlsCert = !!process.env.TLS_CERT;
const hasTlsKey = !!process.env.TLS_KEY;
if (!hasTlsCert && !hasTlsKey) return;
const hasSslCertPath = !!process.env.SSL_CERT_PATH;
const hasSslKeyPath = !!process.env.SSL_KEY_PATH;
if (hasTlsCert || hasTlsKey) {
console.warn('');
console.warn(' ┌─────────────────────────────────────────────────────┐');
console.warn(' │ ⚠ MISCONFIGURATION WARNING — TLS / SSL │');
console.warn(' ├─────────────────────────────────────────────────────┤');
console.warn(' │ TLS_CERT / TLS_KEY are Go server environment │');
console.warn(' │ variables and are IGNORED by this Node.js console. │');
console.warn(' │ │');
console.warn(' │ To enable HTTPS on this console set: │');
console.warn(' │ SSL_CERT_PATH=/path/to/fullchain.pem │');
console.warn(' │ SSL_KEY_PATH=/path/to/privkey.pem │');
console.warn(' │ │');
if (!hasSslCertPath && !hasSslKeyPath) {
console.warn(' │ ❌ SSL_CERT_PATH and SSL_KEY_PATH are NOT set. │');
console.warn(' │ Port 21121 (RustDesk Client API) is HTTP. │');
console.warn(' │ Clients connecting via HTTPS will fail with │');
console.warn(' │ InvalidContentType errors. │');
} else {
console.warn(' │ ✅ SSL_CERT_PATH / SSL_KEY_PATH are set — OK. │');
}
console.warn(' └─────────────────────────────────────────────────────┘');
console.warn('');
}
}
/**
* Load SSL certificates for HTTPS
*/
function loadSslCertificates() {
const options = {};
if (!config.sslCertPath || !config.sslKeyPath) {
return null;
}
try {
if (!fs.existsSync(config.sslCertPath)) {
console.error(`SSL certificate not found: ${config.sslCertPath}`);
return null;
}
if (!fs.existsSync(config.sslKeyPath)) {
console.error(`SSL private key not found: ${config.sslKeyPath}`);
return null;
}
options.cert = fs.readFileSync(config.sslCertPath);
options.key = fs.readFileSync(config.sslKeyPath);
// Optional CA bundle (for Let's Encrypt chain)
if (config.sslCaPath && fs.existsSync(config.sslCaPath)) {
options.ca = fs.readFileSync(config.sslCaPath);
}
return options;
} catch (err) {
console.error('Failed to load SSL certificates:', err.message);
return null;
}
}
function attachPlainHttpTlsHint(server, port) {
server.on('tlsClientError', (err, socket) => {
const message = String(err && err.message || '');
const looksLikePlainHttp = /wrong version number|http request|unknown protocol|packet length/i.test(message);
if (!looksLikePlainHttp || !socket || socket.destroyed) return;
const body = JSON.stringify({
error: `RustDesk Client API on port ${port} requires HTTPS. Use https://<server>:${port}.`
});
const response = [
'HTTP/1.1 400 Bad Request',
'Content-Type: application/json; charset=utf-8',
'Cache-Control: no-store',
'Connection: close',
`Content-Length: ${Buffer.byteLength(body)}`,
'',
body
].join('\r\n');
try {
socket.end(response);
} catch (_) {
socket.destroy();
}
console.warn(`RustDesk API: rejected plain HTTP on HTTPS port ${port}`);
});
}
function shouldUseRustDeskApiTls(sslOptions) {
const mode = String(config.rustdeskApiTls || 'auto').toLowerCase();
if (mode === 'false' || mode === '0' || mode === 'off' || mode === 'http') return false;
if (mode === 'true' || mode === '1' || mode === 'on' || mode === 'https') return !!sslOptions;
return !!sslOptions;
}
/**
* Create HTTP redirect server (redirects all HTTP to HTTPS)
*/
function createHttpRedirectServer(httpsPort) {
const redirectApp = express();
redirectApp.use((req, res) => {
const httpsUrl = formatHttpsRedirectUrl(req.hostname, httpsPort, req.url);
res.setHeader('Cache-Control', 'no-store');
res.redirect(307, httpsUrl);
});
return http.createServer(redirectApp);
}
async function startServer() {
// Warn early about common TLS misconfiguration (Go env vars used instead of Node.js vars)
warnGoTlsEnvVars();
try {
// Initialize database adapter (creates tables, runs migrations)
await db.init();
// Warm branding cache from database (must run after db.init)
const brandingService = require('./services/brandingService');
await brandingService.loadBranding();
// Recover/sync global users before deciding whether a default admin is needed.
// This protects upgrades where local auth.db was recreated but Go still has users.
await userSync.backfillFromGo();
// Ensure default admin exists
await authService.ensureDefaultAdmin();
// Keep Go organization-linkable users aligned with the panel store.
await userSync.backfillFromNode();
let server;
let protocol = 'http';
const listenPort = resolvePortForCurrentUser(config.port, 5000, 'HTTP');
const listenHttpsPort = resolvePortForCurrentUser(config.httpsPort, 5443, 'HTTPS');
let displayPort = listenPort;
// HTTPS mode
if (config.httpsEnabled) {
const sslOptions = loadSslCertificates();
if (sslOptions) {
// Create HTTPS server
server = https.createServer(sslOptions, app);
protocol = 'https';
displayPort = listenHttpsPort;
attachPrivilegedPortErrorHandler(server, { port: listenHttpsPort, label: 'HTTPS' });
server.listen(listenHttpsPort, config.host, () => {
printStartupBanner(protocol, displayPort);
});
// Optionally start HTTP redirect server
if (config.httpRedirect) {
const redirectServer = createHttpRedirectServer(listenHttpsPort);
attachPrivilegedPortErrorHandler(redirectServer, { port: listenPort, label: 'HTTP redirect' });
redirectServer.listen(listenPort, config.host, () => {
console.log(` HTTP -> HTTPS redirect active on port ${listenPort}`);
console.log('');
});
// Graceful shutdown for redirect server too
const shutdownRedirect = () => { redirectServer.close(); };
process.on('SIGTERM', shutdownRedirect);
process.on('SIGINT', shutdownRedirect);
}
} else {
console.warn('WARNING: HTTPS enabled but certificates not found/invalid');
console.warn('Falling back to HTTP mode');
console.warn(' → Session and CSRF cookies downgraded to non-Secure');
console.warn(' → Fix: check SSL_CERT_PATH and SSL_KEY_PATH in .env');
// BD-2026-082: Downgrade cookie flags to match actual HTTP mode.
// Session middleware was initialised with secure:true at module
// load time. Without this fixup, browsers would ignore all
// cookies and every request would fail CSRF validation.
if (sessionMiddleware && sessionMiddleware.options) {
sessionMiddleware.options.cookie = sessionMiddleware.options.cookie || {};
sessionMiddleware.options.cookie.secure = false;
}
csrfDowngradeToHttp();
server = http.createServer(app);
attachPrivilegedPortErrorHandler(server, { port: listenPort, label: 'HTTP' });
server.listen(listenPort, config.host, () => {
printStartupBanner(protocol, listenPort);
});
}
} else {
// HTTP mode (default)
server = http.createServer(app);
attachPrivilegedPortErrorHandler(server, { port: listenPort, label: 'HTTP' });
server.listen(listenPort, config.host, () => {
printStartupBanner(protocol, listenPort);
});
}
// Initialize WebSocket proxy for remote desktop client
initWsProxy(server, sessionMiddleware);
// Initialize BetterDesk native relay (WebSocket)
initBdRelay(server);
// Initialize Chat relay (WebSocket — agent ↔ operator, persistent via Go API)
initChatRelay(server, sessionMiddleware, goApiClient);
// Initialize Remote Desktop relay (WebSocket — agent JPEG ↔ browser viewer)
initRemoteRelay(server, sessionMiddleware);
// Initialize CDAP Terminal WebSocket proxy (browser ↔ Go server)
initCdapTerminalProxy(server, sessionMiddleware);
// Initialize CDAP Media WebSocket proxies (desktop, video, file browser)
initCdapMediaProxies(server, sessionMiddleware);
initMeshAshxProxy(server, sessionMiddleware);
// Initialize real-time device status push (Go event bus → browser)
initDeviceStatusPush(server, sessionMiddleware, config.betterdeskApiUrl, config.betterdeskApiKey);
initHelpRequestEmailService(config.betterdeskApiUrl, config.betterdeskApiKey);
// Start LAN Discovery UDP service
startDiscoveryService();
try {
const panelDiscovery = require('./services/panelDiscovery');
panelDiscovery.startPanelMdns();
} catch (err) {
console.warn('[server] mDNS panel discovery disabled:', err.message);
}
// Start branded agent installer build worker (Generator Agenta / Phase 2).
// Disabled when AGENT_BUILD_WORKER=off — useful for hosts without the
// build toolchain (e.g. small consoles that only proxy to a build node).
if (process.env.AGENT_BUILD_WORKER !== 'off') {
try {
const agentBuildWorker = require('./services/agentBuildWorker');
agentBuildWorker.startWorker();
} catch (err) {
console.warn('[server] agent build worker disabled:', err.message);
}
}
if (process.env.RDCLIENT_BUILD_WORKER !== 'off') {
try {
const rdclientBuildWorker = require('./services/rdclientBuildWorker');
rdclientBuildWorker.startWorker();
} catch (err) {
console.warn('[server] rdclient build worker disabled:', err.message);
}
}
if (process.env.AGENT_CLIENT_BUILD_WORKER !== 'off') {
try {
const agentClientBuildWorker = require('./services/agentClientBuildWorker');
agentClientBuildWorker.startWorker();
} catch (err) {
console.warn('[server] agent-client build worker disabled:', err.message);
}
}
// ============ RustDesk Client API (WAN :21121 → Go :21114 proxy) ============
let apiServer = null;
if (config.apiEnabled) {
apiServer = startRustDeskApiServer();
} else if (config.serverBackend === 'betterdesk') {
console.log(` ║ Client API: disabled — use :${config.apiPort} proxy or Go :${config.goApiPort}`.padEnd(53) + '║');
}
// ============ Periodic Housekeeping ============
const housekeepingInterval = setInterval(async () => {
await authService.cleanupHousekeeping();
// Clean up old integration data (metrics >7d, audit >90d)
try {
await db.runIntegrationHousekeeping();
} catch (err) {
// Silent fail — don't crash the server for housekeeping
}
// Clean up old audit_log entries (>90 days)
try {
if (typeof db.cleanupOldAuditLogs === 'function') {
await db.cleanupOldAuditLogs(90);
}
} catch (err) {
// Silent fail
}
}, 60 * 60 * 1000); // Every hour
// ============ Periodic Online Status Sync ============
const syncInterval = parseInt(process.env.STATUS_SYNC_INTERVAL, 10) || 15; // seconds
const heartbeatStaleThreshold = parseInt(process.env.HEARTBEAT_STALE_THRESHOLD, 10) || 90; // seconds
const statusSyncInterval = setInterval(async () => {
try {
await serverBackend.syncOnlineStatus();
} catch (err) {
// Silent fail - don't crash the server
}
// Also clean up stale heartbeat-based online status
try {
if (typeof db.cleanupStaleOnlinePeers === 'function') {
await db.cleanupStaleOnlinePeers(heartbeatStaleThreshold);
}
} catch (err) {
// Silent fail
}
}, syncInterval * 1000);
// Initial sync on startup (after short delay for HBBS to be ready)
setTimeout(async () => {
try {
const result = await serverBackend.syncOnlineStatus();
if (result.synced > 0) {
console.log(`Initial status sync: ${result.synced} device(s) online`);
}
} catch (err) {
// Silent fail
}
}, 5000);
// Graceful shutdown
const shutdown = (signal) => {
console.log(`\n${signal} received. Shutting down gracefully...`);
clearInterval(housekeepingInterval);
clearInterval(statusSyncInterval);
const closePromises = [new Promise(r => server.close(r))];
if (apiServer) {
closePromises.push(new Promise(r => apiServer.close(r)));
}
Promise.all(closePromises).then(() => {
console.log('All servers closed.');
process.exit(0);
});
// Force exit after 10 seconds
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
}
/**
* Start the dedicated RustDesk Client API server on a separate port.
* This is a minimal, hardened Express app with only 4 endpoints.
* Designed for WAN/internet exposure with aggressive security.
*/
function startRustDeskApiServer() {
const apiApp = express();
const { goApiProxy, getGoApiOrigin } = require('./middleware/goApiProxy');
const useGoProxy = config.apiProxyToGo && config.serverBackend === 'betterdesk';
// Trust proxy (use same configuration as main app — TRUST_PROXY env var)
apiApp.set('trust proxy', trustProxy);
// Apply WAN security middleware stack
const wanMiddleware = getWanMiddlewareStack();
for (const mw of wanMiddleware) {
apiApp.use(mw);
}
if (useGoProxy) {
// LAN registration is still handled in Node (not on Go API)
const registrationRoutes = require('./routes/registration.routes');
apiApp.use('/api/bd', registrationRoutes);
// /api/group* → Go (JWT from /api/login). Node requireAuth only accepts 64-char auth.db tokens.
apiApp.use(goApiProxy);
} else {
// Legacy: Node implements RustDesk API locally (SQLite-era deployments)
apiApp.use(express.json({ limit: '64kb', strict: true }));
apiApp.use('/', rustdeskApiRoutes);
const registrationRoutes = require('./routes/registration.routes');
apiApp.use('/api/bd', registrationRoutes);
}
// Catch-all for any unmatched routes (should not reach here due to pathWhitelist).
// We log every miss so missing RustDesk client compatibility endpoints are
// easy to spot in operations. Diagnostics suggestion credit:
// progloto (PR #81).
apiApp.use((req, res) => {
const ip = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || 'unknown';
const ua = String(req.headers['user-agent'] || '').slice(0, 80);
console.warn(`[rustdesk-api] 404 ${req.method} ${req.originalUrl} from ${ip} ua="${ua}"`);
res.status(404).end();
});
// Error handler — never leak internal errors
apiApp.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') {
console.warn('RustDesk API: JSON parse error from', req.socket?.remoteAddress);
return res.status(400).json({ error: 'Invalid JSON' });
}
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'Request too large' });
}
console.error('RustDesk API error:', err.message);
res.status(500).json({ error: 'Server error' });
});
// Start HTTP or HTTPS server for RustDesk Client API. By default TLS is used
// when certs are available, but self-signed deployments may set
// RUSTDESK_API_TLS=false because stock RustDesk clients cannot trust a
// private CA here. Keep that exception explicit: it affects only :21121.
let apiServerInstance;
const sslOptions = loadSslCertificates();
const useApiTls = shouldUseRustDeskApiTls(sslOptions);
if (useApiTls) {
apiServerInstance = https.createServer(sslOptions, apiApp);
attachPlainHttpTlsHint(apiServerInstance, config.apiPort);
console.log(` ║ API TLS: Enabled (HTTPS on :${config.apiPort})`.padEnd(53) + '║');
} else {
if ((config.sslCertPath || config.sslKeyPath) && config.rustdeskApiTls !== 'false') {
console.warn(`WARNING: SSL certs configured but invalid — API running insecure HTTP on :${config.apiPort}`);
}
if (sslOptions && String(config.rustdeskApiTls || '').toLowerCase() === 'false') {
console.warn(`WARNING: RUSTDESK_API_TLS=false — RustDesk Client API is HTTP on :${config.apiPort}. Use only behind a trusted network/VPN or with a low-privilege account.`);
}
apiServerInstance = http.createServer(apiApp);
}
apiServerInstance.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(` ║ API Port: ${config.apiPort} FAILED (port in use)`.padEnd(53) + '║');
console.error(` ║ Hint: Check if hbbs uses the same port, or`.padEnd(53) + '║');
console.error(` ║ set API_PORT env var (default: 21121)`.padEnd(53) + '║');
console.log(' ║ ║');
console.error(`WARNING: RustDesk Client API could not start on port ${config.apiPort}`);
console.error('Likely cause: hbbs API is on the same port. Client API default is 21121.');
console.error('The admin panel continues to run normally on port ' + config.port);
return; // Don't crash — let the panel continue running
}
if (err.code === 'EACCES') {
console.error(`WARNING: RustDesk Client API could not bind port ${config.apiPort} (permission denied)`);
console.error('Ports below 1024 require root or CAP_NET_BIND_SERVICE — set API_PORT to a high port.');
return;
}
throw err;
});
apiServerInstance.listen(config.apiPort, config.apiHost, () => {
if (useGoProxy) {
console.log(` ║ Client API: :${config.apiPort} → Go ${getGoApiOrigin()}`.padEnd(53) + '║');
} else {
console.log(` ║ Client API: :${config.apiPort} (Node local handlers)`.padEnd(53) + '║');
}
console.log(' ║ ║');
});
// Set connection timeout (prevent slow loris)
apiServerInstance.headersTimeout = 15000;
apiServerInstance.requestTimeout = 10000;
apiServerInstance.keepAliveTimeout = 5000;
return apiServerInstance;
}
/**
* Print startup banner with server info
*/
function printStartupBanner(protocol, port) {
const sslStatus = config.httpsEnabled ? '🔒 HTTPS' : '🔓 HTTP';
// API port 21121 can use a separate TLS mode for RustDesk client compatibility.
const apiHasCerts = config.sslCertPath && config.sslKeyPath &&
fs.existsSync(config.sslCertPath) && fs.existsSync(config.sslKeyPath);
const apiProtocol = shouldUseRustDeskApiTls(apiHasCerts ? {} : null) ? 'HTTPS' : 'HTTP';
const apiStatus = config.apiEnabled ? `✅ Port ${config.apiPort} (${apiProtocol})` : '❌ Disabled';
const panelUrl = `${protocol}://${config.host}:${port}`;
const goApiUrl = redactUrlForLog(config.betterdeskApiUrl || process.env.BETTERDESK_API_URL || 'http://localhost:21114/api');
console.log('');
console.log(' ╔══════════════════════════════════════════════════╗');
console.log(' ║ ║');
console.log(' ║ 🖥️ BetterDesk Console v' + config.appVersion.padEnd(23) + ' ║');
console.log(' ║ ║');
console.log(' ╠══════════════════════════════════════════════════╣');
console.log(' ║ ║');
console.log(` ║ Panel: ${panelUrl}`.padEnd(53) + '║');
if (config.httpsEnabled && config.httpRedirect) {
console.log(` ║ Redirect: http://${config.host}:${config.port} → :${config.httpsPort}`.padEnd(53) + '║');
}
console.log(` ║ Client API: ${apiStatus}`.padEnd(53) + '║');
console.log(` ║ Go API: ${goApiUrl}`.padEnd(53) + '║');
console.log(` ║ Mode: ${config.nodeEnv}`.padEnd(53) + '║');
console.log(` ║ Security: ${sslStatus}`.padEnd(53) + '║');
const dbLabel = (db.DB_TYPE === 'postgres' || db.DB_TYPE === 'postgresql')
? `PostgreSQL (${process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL).hostname : 'localhost'})`
: path.basename(config.dbPath);
console.log(` ║ Database: ${dbLabel}`.padEnd(53) + '║');
console.log(` ║ Keys: ${config.keysPath}`.padEnd(53) + '║');
console.log(' ║ ║');
console.log(' ╚══════════════════════════════════════════════════╝');
console.log('');
// BD-2026-006: Warn if panel is bound to all interfaces in non-Docker environments
if (config.host === '0.0.0.0' && !config.isDocker) {
console.log(' ⚠️ WARNING [SECURITY]: Panel bound to 0.0.0.0 (all interfaces).');
console.log(' Set HOST=127.0.0.1 in .env to restrict to localhost only.');
console.log('');
}
if (config.isProduction && config.host === '0.0.0.0' && !config.httpsEnabled) {
const betterdeskApi = require('./services/betterdeskApi');
betterdeskApi.getEnrollmentMode().then((result) => {
const mode = (result && result.data && (result.data.mode || result.data)) || 'open';
if (String(mode).toLowerCase() === 'open') {
console.log(' ⛔ ERROR [SECURITY]: Production panel on 0.0.0.0 without HTTPS and enrollment=open.');
console.log(' Prefer managed/locked enrollment, enable HTTPS, or bind HOST=127.0.0.1 behind a reverse proxy.');
console.log('');
}
}).catch(() => { /* Go API may not be ready yet */ });
}
// BD-2026-008: Warn if plaintext credentials file exists
const credFile = path.join(config.keysPath, '.admin_credentials');
if (fs.existsSync(credFile)) {
console.log(' ⚠️ WARNING [SECURITY]: Plaintext .admin_credentials file detected.');
console.log(' Delete it after noting the password: ' + credFile);
console.log('');
}
// BD-2026-009: Warn when proxy trust is enabled
if (trustProxy && trustProxy !== false && trustProxy !== 0) {
console.log(' ⚠️ NOTICE [SECURITY]: TRUST_PROXY is enabled (' + trustProxy + ').');
console.log(' Ensure a trusted reverse proxy sets X-Forwarded-For correctly.');
console.log('');
}
// L-01 (audit 2026-04-10): warn about disabled proxy trust in production
// — rate limiters and audit logs will see the proxy IP, not the client IP.
if (process.env.NODE_ENV === 'production' && (!trustProxy || trustProxy === false || trustProxy === 0)) {
console.log(' ⚠️ WARNING [SECURITY]: NODE_ENV=production but TRUST_PROXY is disabled.');
console.log(' If the panel is behind a reverse proxy (nginx, Cloudflare, ALB,');
console.log(' Traefik…) rate-limit keys and audit logs will record the proxy IP,');
console.log(' not the real client IP. Set TRUST_PROXY=1 (single proxy) or a CIDR list.');
console.log('');
}
// H-04 (audit 2026-04-10): unconditional banner when the RustDesk client
// API TOTP bypass is enabled, regardless of acknowledgement — the bypass
// weakens 2FA on the WAN-facing :21121 endpoint and operators MUST be
// aware of it on every restart.
if (config.rustdeskApiDisableTotp) {
if (!config.rustdeskApiDisableTotpAck) {
console.log(' ⛔ ERROR [SECURITY]: RUSTDESK_API_DISABLE_TOTP=true but ACK flag is missing.');
console.log(' The bypass is IGNORED. Set RUSTDESK_API_DISABLE_TOTP_ACKNOWLEDGED=true');
console.log(' to confirm you accept disabling 2FA on the RustDesk client login.');
console.log('');
} else {
console.log(' ⚠️ WARNING [SECURITY]: TOTP is DISABLED on the RustDesk client API (:21121).');
console.log(' RustDesk desktop clients can log in with username+password only.');
console.log(' The web panel still enforces 2FA independently.');
console.log('');
}
}
}
// Start the server
startServer();
module.exports = app;