const express = require('express'); const fs = require('fs').promises; const { spawn } = require('child_process'); const chokidar = require('chokidar'); const paths = require('../utils/paths'); const ConfigParser = require('../utils/config-parser'); const router = express.Router(); // Security function to validate configuration overrides function validateConfigOverrides(configOverrides) { if (!configOverrides || typeof configOverrides !== 'object') { return {}; } const sanitized = {}; // Whitelist of allowed configuration parameters const allowedConfigKeys = [ 'port', 'bind', 'name', 'motd', 'max_users', 'password', 'default_game', 'enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password', 'server_announce', 'serverlist_url', 'enable_pvp', 'time_speed', 'day_night_ratio', 'max_simultaneous_block_sends_per_client', 'max_block_send_distance', 'max_block_generate_distance', 'secure', 'enable_client_modding', 'csm_restriction_flags', 'csm_restriction_noderange', 'player_transfer_distance', 'max_packets_per_iteration', 'dedicated_server_step', 'ignore_world_load_errors', 'remote_media' ]; for (const [key, value] of Object.entries(configOverrides)) { // Validate key if (!allowedConfigKeys.includes(key) || !/^[a-z_]+$/.test(key)) { continue; // Skip invalid keys } // Validate and sanitize value let sanitizedValue = String(value).trim(); // Remove control characters sanitizedValue = sanitizedValue.replace(/[\x00-\x1F\x7F]/g, ''); // Limit length if (sanitizedValue.length > 200) { continue; // Skip overly long values } // Type-specific validation if (['port', 'max_users', 'time_speed', 'max_simultaneous_block_sends_per_client', 'max_block_send_distance', 'max_block_generate_distance', 'csm_restriction_noderange', 'player_transfer_distance', 'max_packets_per_iteration', 'dedicated_server_step'].includes(key)) { const numValue = parseInt(sanitizedValue, 10); if (!isNaN(numValue) && numValue >= 0 && numValue <= 65535) { sanitized[key] = numValue.toString(); } } else if (['enable_damage', 'creative_mode', 'enable_rollback_recording', 'disallow_empty_password', 'server_announce', 'enable_pvp', 'secure', 'enable_client_modding', 'ignore_world_load_errors'].includes(key)) { if (['true', 'false'].includes(sanitizedValue.toLowerCase())) { sanitized[key] = sanitizedValue.toLowerCase(); } } else if (['bind', 'name', 'motd', 'password', 'default_game', 'serverlist_url'].includes(key)) { // String values - ensure they don't contain shell metacharacters if (!/[;&|`$(){}[\]<>\\]/.test(sanitizedValue)) { sanitized[key] = sanitizedValue; } } else { // Floating point values const floatValue = parseFloat(sanitizedValue); if (!isNaN(floatValue) && isFinite(floatValue)) { sanitized[key] = floatValue.toString(); } } } return sanitized; } // Global server state let serverProcess = null; let serverStatus = 'stopped'; let serverLogs = []; let logWatcher = null; // Server management page router.get('/', async (req, res) => { try { paths.ensureDirectories(); // Get available worlds for dropdown let worlds = []; try { const worldDirs = await fs.readdir(paths.worldsDir); for (const worldDir of worldDirs) { try { const worldPath = paths.getWorldPath(worldDir); const configPath = paths.getWorldConfigPath(worldDir); const stats = await fs.stat(worldPath); if (stats.isDirectory()) { const config = await ConfigParser.parseWorldConfig(configPath); worlds.push({ name: worldDir, displayName: config.server_name || worldDir }); } } catch {} } } catch {} // Get recent logs let recentLogs = []; try { const logContent = await fs.readFile(paths.debugFile, 'utf8'); const lines = logContent.split('\n').filter(line => line.trim()); recentLogs = lines.slice(-50); // Last 50 lines } catch { // Debug file might not exist } const serverInfo = { status: serverStatus, pid: serverProcess ? serverProcess.pid : null, uptime: serverProcess ? Date.now() - serverProcess.startTime : 0, logs: [...recentLogs, ...serverLogs.map(log => log.message || log)].slice(-100) }; res.render('server/index', { title: 'Server Management', server: serverInfo, worlds: worlds, currentPage: 'server', scripts: ['server.js'] }); } catch (error) { console.error('Error loading server page:', error); res.status(500).render('error', { error: 'Failed to load server management', message: error.message }); } }); // Get server status (API) router.get('/api/status', (req, res) => { res.json({ status: serverStatus, pid: serverProcess ? serverProcess.pid : null, uptime: serverProcess ? Date.now() - serverProcess.startTime : 0 }); }); // Get server logs (API) router.get('/api/logs', async (req, res) => { try { const limit = parseInt(req.query.limit) || 100; const offset = parseInt(req.query.offset) || 0; let fileLogs = []; try { const logContent = await fs.readFile(paths.debugFile, 'utf8'); const lines = logContent.split('\n').filter(line => line.trim()); fileLogs = lines.slice(-1000); } catch {} const allLogs = [...fileLogs, ...serverLogs.map(log => log.message || log)]; const paginatedLogs = allLogs.slice(offset, offset + limit); res.json({ logs: paginatedLogs, total: allLogs.length, offset, limit }); } catch (error) { console.error('Error getting logs:', error); res.status(500).json({ error: 'Failed to get logs' }); } }); // Start server router.post('/start', async (req, res) => { try { if (serverProcess && serverStatus === 'running') { return res.status(409).json({ error: 'Server is already running' }); } const { worldName, configOverrides } = req.body; if (!worldName || !paths.isValidWorldName(worldName)) { if (req.headers.accept && req.headers.accept.includes('application/json')) { return res.status(400).json({ error: 'Valid world name required' }); } else { return res.redirect('/server?error=Valid+world+name+required'); } } const worldPath = paths.getWorldPath(worldName); try { await fs.access(worldPath); } catch { if (req.headers.accept && req.headers.accept.includes('application/json')) { return res.status(404).json({ error: 'World not found' }); } else { return res.redirect('/server?error=World+not+found'); } } const args = [ '--server', '--world', worldPath, '--logfile', paths.debugFile ]; if (configOverrides) { const sanitizedOverrides = validateConfigOverrides(configOverrides); for (const [key, value] of Object.entries(sanitizedOverrides)) { args.push(`--${key}`, value); } } serverProcess = spawn('luanti', args, { stdio: ['pipe', 'pipe', 'pipe'], detached: false }); serverProcess.startTime = Date.now(); serverStatus = 'starting'; serverLogs = []; // Get Socket.IO instance from main app const { io } = require('../app'); serverProcess.stdout.on('data', (data) => { const logLine = data.toString().trim(); if (logLine) { const logEntry = { timestamp: new Date().toISOString(), level: 'info', message: logLine }; serverLogs.push(logEntry); if (serverLogs.length > 1000) { serverLogs = serverLogs.slice(-1000); } if (io) { io.emit('serverLog', logEntry); } } }); serverProcess.stderr.on('data', (data) => { const logLine = data.toString().trim(); if (logLine) { const logEntry = { timestamp: new Date().toISOString(), level: 'error', message: logLine }; serverLogs.push(logEntry); if (serverLogs.length > 1000) { serverLogs = serverLogs.slice(-1000); } if (io) { io.emit('serverLog', logEntry); } } }); serverProcess.on('spawn', () => { serverStatus = 'running'; console.log('Luanti server started'); if (io) { io.emit('serverStatus', { status: serverStatus, pid: serverProcess.pid, uptime: Date.now() - serverProcess.startTime }); } }); serverProcess.on('error', (error) => { console.error('Server error:', error); serverStatus = 'error'; const logEntry = { timestamp: new Date().toISOString(), level: 'error', message: `Server error: ${error.message}` }; serverLogs.push(logEntry); if (io) { io.emit('serverLog', logEntry); io.emit('serverStatus', { status: serverStatus, pid: null, uptime: 0 }); } }); serverProcess.on('exit', (code, signal) => { console.log(`Server exited with code ${code}, signal ${signal}`); serverStatus = 'stopped'; const logEntry = { timestamp: new Date().toISOString(), level: 'info', message: `Server stopped (code: ${code}, signal: ${signal})` }; serverLogs.push(logEntry); serverProcess = null; if (io) { io.emit('serverLog', logEntry); io.emit('serverStatus', { status: serverStatus, pid: null, uptime: 0 }); } }); // Watch debug log file if (logWatcher) { logWatcher.close(); } logWatcher = chokidar.watch(paths.debugFile, { persistent: true }); logWatcher.on('change', async () => { try { const logContent = await fs.readFile(paths.debugFile, 'utf8'); const lines = logContent.split('\n'); const newLines = lines.slice(-10); for (const line of newLines) { if (line.trim() && !serverLogs.some(log => (log.message || log) === line.trim())) { const logEntry = { timestamp: new Date().toISOString(), level: 'info', message: line.trim() }; serverLogs.push(logEntry); if (io) { io.emit('serverLog', logEntry); } } } if (serverLogs.length > 1000) { serverLogs = serverLogs.slice(-1000); } } catch {} }); if (req.headers.accept && req.headers.accept.includes('application/json')) { res.json({ message: 'Server starting', pid: serverProcess.pid }); } else { res.redirect('/server?started=true'); } } catch (error) { console.error('Error starting server:', error); if (req.headers.accept && req.headers.accept.includes('application/json')) { res.status(500).json({ error: 'Failed to start server' }); } else { res.redirect(`/server?error=${encodeURIComponent(error.message)}`); } } }); // Stop server router.post('/stop', (req, res) => { try { if (!serverProcess || serverStatus !== 'running') { if (req.headers.accept && req.headers.accept.includes('application/json')) { return res.status(409).json({ error: 'Server is not running' }); } else { return res.redirect('/server?error=Server+is+not+running'); } } serverStatus = 'stopping'; serverProcess.kill('SIGTERM'); setTimeout(() => { if (serverProcess && serverStatus === 'stopping') { serverProcess.kill('SIGKILL'); } }, 10000); if (logWatcher) { logWatcher.close(); logWatcher = null; } const { io } = require('../app'); if (io) { io.emit('serverStatus', { status: serverStatus, pid: serverProcess ? serverProcess.pid : null, uptime: serverProcess ? Date.now() - serverProcess.startTime : 0 }); } if (req.headers.accept && req.headers.accept.includes('application/json')) { res.json({ message: 'Server stopping' }); } else { res.redirect('/server?stopped=true'); } } catch (error) { console.error('Error stopping server:', error); if (req.headers.accept && req.headers.accept.includes('application/json')) { res.status(500).json({ error: 'Failed to stop server' }); } else { res.redirect(`/server?error=${encodeURIComponent(error.message)}`); } } }); // Send command to server router.post('/command', (req, res) => { try { if (!serverProcess || serverStatus !== 'running') { return res.status(409).json({ error: 'Server is not running' }); } const { command } = req.body; if (!command) { return res.status(400).json({ error: 'Command required' }); } // Validate and sanitize the command using ServerManager's validation const ServerManager = require('../utils/server-manager'); const serverManager = new ServerManager(); try { const sanitizedCommand = serverManager.validateServerCommand(command); serverProcess.stdin.write(sanitizedCommand + '\n'); const logEntry = { timestamp: new Date().toISOString(), level: 'command', message: `> ${sanitizedCommand}` }; serverLogs.push(logEntry); const { io } = require('../app'); if (io) { io.emit('serverLog', logEntry); } res.json({ message: 'Command sent successfully' }); } catch (validationError) { return res.status(400).json({ error: validationError.message }); } } catch (error) { console.error('Error sending command:', error); res.status(500).json({ error: 'Failed to send command' }); } }); // Export server state for use in main app router.getServerState = () => ({ process: serverProcess, status: serverStatus, logs: serverLogs }); module.exports = router;