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>
473 lines
14 KiB
JavaScript
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; |