/** * BetterDesk Console - Server Entry Point * Professional Web Management Panel for RustDesk Server * * @author UNITRONIX * @version 2.1.0 * @license Apache-2.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 securityMiddleware = require('./middleware/security'); const { initI18n } = require('./middleware/i18n'); const { apiLimiter } = 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 { 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 { startDiscoveryService } = require('./services/lanDiscovery'); const { initDeviceStatusPush } = require('./services/deviceStatusPush'); const routes = require('./routes'); const rustdeskApiRoutes = require('./routes/rustdesk-api.routes'); const bdApiRoutes = require('./routes/bd-api.routes'); const { getWanMiddlewareStack } = require('./middleware/wanSecurity'); // Create Express app const app = express(); // Trust proxy (for rate limiting behind reverse proxy) // Configurable via TRUST_PROXY env var: 0=disabled, 1=single proxy, 'loopback'=localhost only // Default: false (safest). Set TRUST_PROXY=1 when behind nginx/Apache/cloudflare const trustProxy = process.env.TRUST_PROXY !== undefined ? (isNaN(process.env.TRUST_PROXY) ? process.env.TRUST_PROXY : parseInt(process.env.TRUST_PROXY, 10)) : false; app.set('trust proxy', trustProxy); // View engine setup app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); // Ensure data directory exists if (!fs.existsSync(config.dataDir)) { fs.mkdirSync(config.dataDir, { recursive: true }); } // ============ 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). const SESSION_COOKIE = config.httpsEnabled ? 'betterdesk.sid' : 'bd.sid'; const sessionMiddleware = session({ secret: config.sessionSecret, name: SESSION_COOKIE, resave: false, saveUninitialized: false, 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 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'; // 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 use Bearer token // or X-Device-Id header authentication, not browser cookie-based CSRF. app.use(csrfTokenProvider); app.use((req, res, next) => { if (req.path.startsWith('/api/bd/')) { return next(); } // Skip CSRF for BetterDesk desktop clients (Tauri) — they are not // vulnerable to CSRF attacks (not browser tabs). Identified by origin. const origin = req.headers.origin || ''; const tauriOrigins = ['http://localhost:1420', 'tauri://localhost', 'https://tauri.localhost']; if (req.path.startsWith('/api/') && tauriOrigins.includes(origin)) { 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); 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) => { console.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 ============ /** * 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; } } /** * Create HTTP redirect server (redirects all HTTP to HTTPS) */ function createHttpRedirectServer() { const redirectApp = express(); redirectApp.use((req, res) => { const httpsUrl = `https://${req.hostname}:${config.httpsPort}${req.url}`; res.redirect(301, httpsUrl); }); return http.createServer(redirectApp); } async function startServer() { 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(); // Ensure default admin exists await authService.ensureDefaultAdmin(); let server; let protocol = 'http'; let displayPort = config.port; // HTTPS mode if (config.httpsEnabled) { const sslOptions = loadSslCertificates(); if (sslOptions) { // Create HTTPS server server = https.createServer(sslOptions, app); protocol = 'https'; displayPort = config.httpsPort; server.listen(config.httpsPort, config.host, () => { printStartupBanner(protocol, displayPort); }); // Optionally start HTTP redirect server if (config.httpRedirect) { const redirectServer = createHttpRedirectServer(); redirectServer.listen(config.port, config.host, () => { console.log(` HTTP -> HTTPS redirect active on port ${config.port}`); 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); server.listen(config.port, config.host, () => { printStartupBanner(protocol, config.port); }); } } else { // HTTP mode (default) server = http.createServer(app); server.listen(config.port, config.host, () => { printStartupBanner(protocol, config.port); }); } // 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); // Initialize real-time device status push (Go event bus → browser) initDeviceStatusPush(server, sessionMiddleware, config.betterdeskApiUrl, config.betterdeskApiKey); // Start LAN Discovery UDP service startDiscoveryService(); // ============ RustDesk Client API Server (dedicated port) ============ let apiServer = null; if (config.apiEnabled) { apiServer = startRustDeskApiServer(); } // ============ 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(); // 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); } // JSON body parser with size limit (64KB for address book sync) apiApp.use(express.json({ limit: '64kb', strict: true })); // Mount RustDesk-compatible API routes apiApp.use('/', rustdeskApiRoutes); // Mount device-facing registration routes (LAN discovery pairing) const registrationRoutes = require('./routes/registration.routes'); apiApp.use('/api/bd', registrationRoutes); // Catch-all for any unmatched routes (should not reach here due to pathWhitelist) apiApp.use((req, res) => { 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 // Port 21121 is internet-facing — always use TLS if valid certs are available, // regardless of HTTPS_ENABLED (which controls the admin panel port). let apiServerInstance; const sslOptions = loadSslCertificates(); if (sslOptions) { apiServerInstance = https.createServer(sslOptions, apiApp); console.log(` ║ API TLS: Enabled (HTTPS on :${config.apiPort})`.padEnd(53) + '║'); } else { if (config.sslCertPath || config.sslKeyPath) { console.warn(`WARNING: SSL certs configured but invalid — API running insecure HTTP on :${config.apiPort}`); } 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 } throw err; }); apiServerInstance.listen(config.apiPort, config.apiHost, () => { console.log(` ║ API Port: ${config.apiPort} (RustDesk Client)`.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 auto-enables TLS if valid certs exist (regardless of HTTPS_ENABLED) const apiHasCerts = config.sslCertPath && config.sslKeyPath && fs.existsSync(config.sslCertPath) && fs.existsSync(config.sslKeyPath); const apiProtocol = apiHasCerts ? 'HTTPS' : 'HTTP'; const apiStatus = config.apiEnabled ? `✅ Port ${config.apiPort} (${apiProtocol})` : '❌ Disabled'; const panelUrl = `${protocol}://${config.host}:${port}`; 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: http://localhost:21114/api`.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(''); } // 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(''); } } // Start the server startServer(); module.exports = app;