Files
LuHost/routes/server.js
Nathan Schneider 3aed09b60f Initial commit: LuHost - Luanti Server Management Web Interface
A modern web interface for Luanti (Minetest) server management with ContentDB integration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 17:32:37 -06:00

473 lines
14 KiB
JavaScript

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;