mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
5b4408a190
Security hardening and installer fixes across the Go API, installers and Node console. Key changes: - WebSocket: removed InsecureSkipVerify and added API_WS_ALLOWED_ORIGINS allowlist (parsed in config) used by the API events WS endpoint. - Node.js console: added HOST and API_HOST envs and used apiHost in the server; docker-compose and installer templates updated accordingly. - Admin credentials: plaintext .admin_credentials persistence is now opt-in via STORE_ADMIN_CREDENTIALS (default false); installers and reset flows no longer persist creds unless explicitly enabled. - Installer hardening: added sql_escape_literal for safe SQL literals, PostgreSQL identifier validation, and safer CREATE/ALTER/psql invocations; API key and password updates now pass secrets via environment variables to Python/Node fallbacks to avoid unsafe shell interpolation. - Docker compose/scripts: preserve_compose_database_config to keep DB mode during regen, escaped API key insertion into sqlite, and various compose generation formatting fixes. - Go toolchain & checks: go.mod toolchain set to go1.26.1 and installers now reject known-vulnerable Go 1.26.0 stdlib. - Dependency: bumped web-nodejs tar override to ^7.5.11. These changes reduce attack surface for cross-origin WS usage, eliminate unsafe credential persistence by default, and harden installer DB operations and password reset paths.
500 lines
18 KiB
JavaScript
500 lines
18 KiB
JavaScript
/**
|
|
* 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 } = require('./middleware/csrf');
|
|
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 { initRemoteRelay } = require('./services/remoteRelay');
|
|
const { startDiscoveryService } = require('./services/lanDiscovery');
|
|
const routes = require('./routes');
|
|
const rustdeskApiRoutes = require('./routes/rustdesk-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);
|
|
|
|
// 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
|
|
const sessionMiddleware = session({
|
|
secret: config.sessionSecret,
|
|
name: 'betterdesk.sid',
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: config.httpsEnabled,
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
maxAge: config.sessionMaxAge
|
|
}
|
|
});
|
|
app.use(sessionMiddleware);
|
|
|
|
// Static files
|
|
app.use(express.static(path.join(__dirname, 'public'), {
|
|
maxAge: config.isProduction ? '1d' : '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
|
|
}));
|
|
|
|
// 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);
|
|
|
|
// i18n middleware
|
|
app.use(initI18n());
|
|
|
|
// CSRF protection — generate token for views, validate on POST/PUT/DELETE/PATCH
|
|
app.use(csrfTokenProvider);
|
|
app.use(doubleCsrfProtection);
|
|
|
|
// ============ 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);
|
|
// 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.' });
|
|
}
|
|
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.'
|
|
});
|
|
}
|
|
return res.json({ success: false, error: 'Invalid CSRF token' });
|
|
}
|
|
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');
|
|
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);
|
|
|
|
// Initialize BetterDesk native relay (WebSocket)
|
|
initBdRelay(server);
|
|
|
|
// Initialize Chat relay (WebSocket — agent ↔ operator)
|
|
initChatRelay(server, sessionMiddleware);
|
|
|
|
// Initialize Remote Desktop relay (WebSocket — agent JPEG ↔ browser viewer)
|
|
initRemoteRelay(server, sessionMiddleware);
|
|
|
|
// 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
|
|
}
|
|
}, 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
|
|
let apiServerInstance;
|
|
if (config.httpsEnabled) {
|
|
const sslOptions = loadSslCertificates();
|
|
if (sslOptions) {
|
|
apiServerInstance = https.createServer(sslOptions, apiApp);
|
|
console.log(` ║ API TLS: Enabled (HTTPS)`.padEnd(53) + '║');
|
|
} else {
|
|
console.warn('WARNING: HTTPS enabled but SSL certs invalid — API falling back to HTTP');
|
|
apiServerInstance = http.createServer(apiApp);
|
|
}
|
|
} else {
|
|
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';
|
|
const apiStatus = config.apiEnabled ? `✅ Port ${config.apiPort}` : '❌ Disabled';
|
|
console.log('');
|
|
console.log(' ╔══════════════════════════════════════════════════╗');
|
|
console.log(' ║ ║');
|
|
console.log(' ║ 🖥️ BetterDesk Console v' + config.appVersion.padEnd(23) + ' ║');
|
|
console.log(' ║ ║');
|
|
console.log(' ╠══════════════════════════════════════════════════╣');
|
|
console.log(' ║ ║');
|
|
console.log(` ║ Panel: ${protocol}://${config.host}:${port}`.padEnd(53) + '║');
|
|
console.log(` ║ Client API: ${apiStatus}`.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(' ║ ║');
|
|
console.log(' ╚══════════════════════════════════════════════════╝');
|
|
console.log('');
|
|
}
|
|
|
|
// Start the server
|
|
startServer();
|
|
|
|
module.exports = app;
|