Major server management fixes: - Replace Flatpak-specific pkill with universal process tree termination using pstree + process.kill() - Fix signal format errors (SIGTERM/SIGKILL instead of TERM/KILL strings) - Add 5-second cooldown after server stop to prevent race conditions with external detection - Enable Stop Server button for external servers in UI - Implement proper timeout handling with process tree killing ContentDB improvements: - Fix download retry logic and "closed" error by preventing concurrent zip extraction - Implement smart root directory detection and stripping during package extraction - Add game-specific timeout handling (8s for VoxeLibre vs 3s for simple games) World creation fixes: - Make world creation asynchronous to prevent browser hangs - Add WebSocket notifications for world creation completion status Other improvements: - Remove excessive debug logging - Improve error handling and user feedback throughout the application - Clean up temporary files and unnecessary logging 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
525 lines
14 KiB
JavaScript
525 lines
14 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
|
|
const paths = require('../utils/paths');
|
|
const serverManager = require('../utils/shared-server-manager');
|
|
const ConfigManager = require('../utils/config-manager');
|
|
const ConfigParser = require('../utils/config-parser');
|
|
const appConfig = require('../utils/app-config');
|
|
|
|
const router = express.Router();
|
|
|
|
// Create global config manager instance
|
|
const configManager = new ConfigManager();
|
|
|
|
// Initialize server manager with socket.io when available
|
|
let io = null;
|
|
|
|
function setSocketIO(socketInstance) {
|
|
io = socketInstance;
|
|
|
|
// Attach server manager events to socket.io
|
|
serverManager.on('log', (logEntry) => {
|
|
if (io) {
|
|
io.emit('server:log', logEntry);
|
|
}
|
|
});
|
|
|
|
serverManager.on('stats', (stats) => {
|
|
if (io) {
|
|
io.emit('server:stats', stats);
|
|
}
|
|
});
|
|
|
|
serverManager.on('status', (status) => {
|
|
if (io) {
|
|
io.emit('server:status', status);
|
|
}
|
|
});
|
|
|
|
serverManager.on('exit', (exitInfo) => {
|
|
if (io) {
|
|
// Broadcast status immediately when server exits
|
|
serverManager.getServerStatus().then(status => {
|
|
io.emit('server:status', status);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Server status endpoint
|
|
router.get('/server/status', async (req, res) => {
|
|
try {
|
|
const status = await serverManager.getServerStatus();
|
|
|
|
// For all running servers, get player list from debug.txt
|
|
let playerList = [];
|
|
if (status.isRunning) {
|
|
const playerData = await serverManager.getExternalServerPlayerData();
|
|
playerList = playerData.players;
|
|
|
|
// Also update the server stats with current player count
|
|
serverManager.serverStats.players = playerData.count;
|
|
|
|
// Emit player list via WebSocket if available
|
|
if (io) {
|
|
io.emit('server:players', playerList);
|
|
}
|
|
}
|
|
|
|
const isExternal = serverManager.serverProcess?.external || false;
|
|
|
|
res.json({
|
|
...status,
|
|
players: playerList.length, // Override with actual player count
|
|
playerList: playerList,
|
|
// Add simple string status for UI
|
|
statusText: status.isRunning ? 'running' : 'stopped',
|
|
// Include external server information
|
|
isExternal: isExternal
|
|
});
|
|
} catch (error) {
|
|
console.error('API: Server status error:', error);
|
|
res.status(500).json({
|
|
error: error.message,
|
|
statusText: 'stopped',
|
|
isRunning: false,
|
|
isReady: false,
|
|
playerList: []
|
|
});
|
|
}
|
|
});
|
|
|
|
// Start server
|
|
router.post('/server/start', async (req, res) => {
|
|
try {
|
|
const { worldName } = req.body;
|
|
console.log('Server start requested with world:', worldName);
|
|
const result = await serverManager.startServer(worldName);
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error('Server start error:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Stop server
|
|
router.post('/server/stop', async (req, res) => {
|
|
try {
|
|
const { force = false } = req.body;
|
|
const result = await serverManager.stopServer(force);
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Restart server
|
|
router.post('/server/restart', async (req, res) => {
|
|
try {
|
|
const { worldName } = req.body;
|
|
const result = await serverManager.restartServer(worldName);
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Send command to server
|
|
router.post('/server/command', async (req, res) => {
|
|
try {
|
|
const { command } = req.body;
|
|
if (!command || typeof command !== 'string') {
|
|
return res.status(400).json({ error: 'Command is required' });
|
|
}
|
|
|
|
const result = await serverManager.sendCommand(command.trim());
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Get server logs
|
|
router.get('/server/logs', async (req, res) => {
|
|
try {
|
|
const { lines = 500, format = 'text' } = req.query;
|
|
const logs = serverManager.getLogs(parseInt(lines));
|
|
|
|
if (format === 'json') {
|
|
res.json(logs);
|
|
} else {
|
|
// Return as downloadable text file
|
|
const logText = logs.map(log =>
|
|
`[${log.timestamp}] ${log.type.toUpperCase()}: ${log.content}`
|
|
).join('\n');
|
|
|
|
res.setHeader('Content-Disposition', 'attachment; filename=server-logs.txt');
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.send(logText);
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get server info
|
|
router.get('/server/info', async (req, res) => {
|
|
try {
|
|
const info = await serverManager.getServerInfo();
|
|
res.json(info);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Configuration endpoints
|
|
|
|
// Get all configuration sections
|
|
router.get('/config/sections', async (req, res) => {
|
|
try {
|
|
const sections = configManager.getAllSettings();
|
|
res.json(sections);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get current configuration
|
|
router.get('/config', async (req, res) => {
|
|
try {
|
|
// Use the new configuration schema approach instead of ConfigManager
|
|
const configSchema = {
|
|
System: {
|
|
data_directory: {
|
|
type: 'string',
|
|
default: '',
|
|
description: 'Luanti data directory path (leave empty for auto-detection)'
|
|
}
|
|
},
|
|
Server: {
|
|
port: {
|
|
type: 'number',
|
|
default: 30000,
|
|
description: 'Port for server to listen on'
|
|
},
|
|
server_name: {
|
|
type: 'string',
|
|
default: 'Luanti Server',
|
|
description: 'Name of the server'
|
|
},
|
|
server_description: {
|
|
type: 'string',
|
|
default: 'A Luanti server',
|
|
description: 'Server description'
|
|
},
|
|
server_announce: {
|
|
type: 'boolean',
|
|
default: false,
|
|
description: 'Announce server to server list'
|
|
},
|
|
max_users: {
|
|
type: 'number',
|
|
default: 20,
|
|
description: 'Maximum number of users'
|
|
}
|
|
},
|
|
World: {
|
|
creative_mode: {
|
|
type: 'boolean',
|
|
default: false,
|
|
description: 'Enable creative mode by default'
|
|
},
|
|
enable_damage: {
|
|
type: 'boolean',
|
|
default: true,
|
|
description: 'Enable player damage by default'
|
|
},
|
|
enable_pvp: {
|
|
type: 'boolean',
|
|
default: true,
|
|
description: 'Enable player vs player combat by default'
|
|
},
|
|
default_game: {
|
|
type: 'string',
|
|
default: 'minetest_game',
|
|
description: 'Default game to use for new worlds'
|
|
},
|
|
time_speed: {
|
|
type: 'number',
|
|
default: 72,
|
|
description: 'Time speed (72 = normal, higher = faster)'
|
|
}
|
|
},
|
|
Security: {
|
|
disallow_empty_password: {
|
|
type: 'boolean',
|
|
default: false,
|
|
description: 'Disallow empty passwords'
|
|
},
|
|
strict_protocol_version_checking: {
|
|
type: 'boolean',
|
|
default: false,
|
|
description: 'Strict protocol version checking'
|
|
}
|
|
},
|
|
Performance: {
|
|
dedicated_server_step: {
|
|
type: 'number',
|
|
default: 0.1,
|
|
description: 'Server step time in seconds'
|
|
},
|
|
num_emerge_threads: {
|
|
type: 'number',
|
|
default: 1,
|
|
description: 'Number of emerge threads'
|
|
},
|
|
server_map_save_interval: {
|
|
type: 'number',
|
|
default: 15.3,
|
|
description: 'Map save interval in seconds'
|
|
},
|
|
max_block_send_distance: {
|
|
type: 'number',
|
|
default: 12,
|
|
description: 'Maximum block send distance'
|
|
}
|
|
},
|
|
Network: {
|
|
server_address: {
|
|
type: 'string',
|
|
default: '',
|
|
description: 'IP address to bind to (empty for all interfaces)'
|
|
},
|
|
server_dedicated: {
|
|
type: 'boolean',
|
|
default: false,
|
|
description: 'Run as dedicated server'
|
|
}
|
|
},
|
|
Advanced: {
|
|
max_simultaneous_block_sends_per_client: {
|
|
type: 'number',
|
|
default: 40,
|
|
description: 'Maximum simultaneous block sends per client'
|
|
}
|
|
}
|
|
};
|
|
|
|
// Load both Luanti config and app config
|
|
const luantiConfig = await ConfigParser.parseConfig(paths.configFile);
|
|
await appConfig.load();
|
|
|
|
// Combine configs for display
|
|
const combinedConfig = {
|
|
...luantiConfig,
|
|
data_directory: appConfig.getDataDirectory()
|
|
};
|
|
|
|
// Organize schema into sections with proper structure for frontend
|
|
const sections = {};
|
|
for (const [sectionName, sectionFields] of Object.entries(configSchema)) {
|
|
sections[sectionName] = {
|
|
description: sectionName + ' configuration settings',
|
|
settings: sectionFields
|
|
};
|
|
}
|
|
|
|
res.json({
|
|
current: combinedConfig,
|
|
sections: sections,
|
|
schema: configSchema
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting config via API:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Update configuration
|
|
router.post('/config', async (req, res) => {
|
|
try {
|
|
const { settings } = req.body;
|
|
|
|
if (!settings || typeof settings !== 'object') {
|
|
return res.status(400).json({ error: 'Settings object is required' });
|
|
}
|
|
|
|
// Validate all settings
|
|
const validationErrors = [];
|
|
const validatedSettings = {};
|
|
|
|
for (const [key, value] of Object.entries(settings)) {
|
|
const validation = configManager.validateSetting(key, value);
|
|
if (validation.valid) {
|
|
validatedSettings[key] = validation.value;
|
|
} else {
|
|
validationErrors.push({ key, error: validation.error });
|
|
}
|
|
}
|
|
|
|
if (validationErrors.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'Validation failed',
|
|
details: validationErrors
|
|
});
|
|
}
|
|
|
|
const result = await configManager.updateSettings(validatedSettings);
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Update single configuration setting
|
|
router.put('/config/:key', async (req, res) => {
|
|
try {
|
|
const { key } = req.params;
|
|
const { value } = req.body;
|
|
|
|
const validation = configManager.validateSetting(key, value);
|
|
if (!validation.valid) {
|
|
return res.status(400).json({ error: validation.error });
|
|
}
|
|
|
|
const result = await configManager.updateSetting(key, validation.value);
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Reset configuration section to defaults
|
|
router.post('/config/reset/:section?', async (req, res) => {
|
|
try {
|
|
const { section } = req.params;
|
|
const result = await configManager.resetToDefaults(section);
|
|
res.json(result);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get setting information
|
|
router.get('/config/setting/:key', async (req, res) => {
|
|
try {
|
|
const { key } = req.params;
|
|
const info = configManager.getSettingInfo(key);
|
|
|
|
if (!info) {
|
|
return res.status(404).json({ error: 'Setting not found' });
|
|
}
|
|
|
|
res.json(info);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Worlds endpoints (basic)
|
|
router.get('/worlds', async (req, res) => {
|
|
try {
|
|
await fs.mkdir(paths.worldsDir, { recursive: true });
|
|
const worldDirs = await fs.readdir(paths.worldsDir);
|
|
const worlds = [];
|
|
|
|
for (const worldDir of worldDirs) {
|
|
try {
|
|
const worldPath = paths.getWorldPath(worldDir);
|
|
const stats = await fs.stat(worldPath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// Try to read world.mt for display name
|
|
let displayName = worldDir;
|
|
try {
|
|
const worldConfig = await fs.readFile(
|
|
path.join(worldPath, 'world.mt'),
|
|
'utf8'
|
|
);
|
|
const nameMatch = worldConfig.match(/world_name\s*=\s*(.+)/);
|
|
if (nameMatch) {
|
|
displayName = nameMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
}
|
|
} catch {}
|
|
|
|
worlds.push({
|
|
name: worldDir,
|
|
displayName: displayName,
|
|
path: worldPath,
|
|
lastModified: stats.mtime
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// Skip invalid world directories
|
|
}
|
|
}
|
|
|
|
// Sort by last modified
|
|
worlds.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified));
|
|
|
|
res.json(worlds);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ContentDB package info endpoint for validation
|
|
router.post('/contentdb/package-info', async (req, res) => {
|
|
try {
|
|
const { author, name } = req.body;
|
|
|
|
if (!author || !name) {
|
|
return res.status(400).json({ error: 'Author and name are required' });
|
|
}
|
|
|
|
const ContentDBClient = require('../utils/contentdb');
|
|
const contentdb = new ContentDBClient();
|
|
|
|
// Get package info from ContentDB
|
|
const packageInfo = await contentdb.getPackage(author, name);
|
|
|
|
res.json({
|
|
type: packageInfo.type || 'mod',
|
|
title: packageInfo.title || name,
|
|
author: packageInfo.author || author,
|
|
name: packageInfo.name || name,
|
|
short_description: packageInfo.short_description || ''
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting package info:', error);
|
|
|
|
// If it's a 404 error, return that specifically
|
|
if (error.message === 'Package not found') {
|
|
return res.status(404).json({ error: 'Package not found on ContentDB' });
|
|
}
|
|
|
|
// For other errors, return a generic error but don't fail completely
|
|
res.status(200).json({
|
|
error: 'Could not verify package information',
|
|
type: 'mod', // Default to mod type
|
|
fallback: true
|
|
});
|
|
}
|
|
});
|
|
|
|
module.exports = {
|
|
router,
|
|
setSocketIO,
|
|
serverManager,
|
|
configManager
|
|
};
|