Files
GSM/gsm-backend/routes/servers.js
Alexander Zielonka a88437f95f
All checks were successful
Deploy GSM / deploy (push) Successful in 25s
Add world management for Space Engineers (list/switch/create/clone/rename/delete)
Mirrors Factorio's world feature for the SE server. The SE docker image always
loads the flat config/World dir and its entrypoint rewrites <LoadWorld> on every
start, so repointing the cfg is useless. Instead inactive worlds live in a
sibling config/Worlds library (not mounted) and switching is an instant mv-swap;
the active world name is tracked in .gsm-worldname and files are chowned to
1000:1000. New worlds are created from the image's built-in CustomWorlds
templates or by cloning the active world. Switch/clone require a stopped server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:12:45 +02:00

1137 lines
45 KiB
JavaScript

import { Router } from 'express';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { EmbedBuilder } from 'discord.js';
import { authenticateToken, optionalAuth, requireRole, rejectGuest } from '../middleware/auth.js';
import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig, readHytaleConfig, writeHytaleConfig, listSpaceEngineersWorlds, listSpaceEngineersTemplates, switchSpaceEngineersWorld, createSpaceEngineersWorldFromTemplate, cloneSpaceEngineersWorld, deleteSpaceEngineersWorld, renameSpaceEngineersWorld } from '../services/ssh.js';
import { supportsUpdate, checkForUpdate, performUpdate } from '../services/serverUpdates.js';
import { supportsVersionInfo, getVersionInfo, invalidateVersionCache } from '../services/serverVersions.js';
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js';
import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings, initGuildSettings } from '../db/init.js';
import { getEmptySince } from '../services/autoshutdown.js';
import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js';
import { sendUpdateToAllGuilds } from '../services/discordBot.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadConfig() {
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
}
// RAM Budget Checkasync function checkRamBudget(serverToStart) { const config = loadConfig(); const ramBudget = config.ramBudget || 30; let usedRam = 0; for (const server of config.servers) { if (server.id === serverToStart.id) continue; try { const status = await getServerStatus(server); if (status === "online") usedRam += server.maxRam || 0; } catch (err) {} } const serverRam = serverToStart.maxRam || 0; const availableRam = ramBudget - usedRam; return { canStart: availableRam >= serverRam, usedRam, serverRam, availableRam, ramBudget };}
// Initialize tables
initWhitelistCache();
initActivityLog();
initFactorioTemplates();
initFactorioWorldSettings();
initServerDisplaySettings();
initGuildSettings();
const router = Router();
function formatBytes(bytes, forceUnit = null) {
if (bytes === 0) return { value: 0, unit: forceUnit || "B" };
const gb = bytes / (1024 * 1024 * 1024);
const mb = bytes / (1024 * 1024);
if (forceUnit === "GB") return { value: gb, unit: "GB" };
if (forceUnit === "MB") return { value: mb, unit: "MB" };
if (gb >= 1) return { value: gb, unit: "GB" };
return { value: mb, unit: "MB" };
}
// ---- Server status aggregation cache ----
// Building a server's status payload means SSH round-trips + 6 Prometheus queries.
// Both GET / (dashboard) and GET /:id (detail page) need exactly the same data, and
// the frontend polls every 10s — often from several tabs/users at once. A short-TTL
// cache collapses all of that into one computation per server every few seconds, and
// the in-flight map prevents a stampede when several requests miss the cache together.
const SERVER_STATUS_TTL = 5000; // ms
const serverPayloadCache = new Map(); // id -> { time, data }
const serverPayloadInflight = new Map(); // id -> Promise
function invalidateServerPayload(id) {
serverPayloadCache.delete(id);
serverPayloadInflight.delete(id);
}
async function computeServerPayload(server) {
// Host marked unreachable by the SSH breaker -> skip the expensive SSH calls,
// still surface whatever Prometheus has (it scrapes independently of SSH).
if (isHostFailed(server.host, server.sshUser)) {
const metrics = await getCurrentMetrics(server.id).catch(() => ({
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
}));
const memTotal = formatBytes(metrics.memoryTotal);
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
return {
id: server.id,
name: server.name,
type: server.type,
status: "unreachable",
running: false,
metrics: {
cpu: metrics.cpu,
cpuCores: metrics.cpuCores,
memory: metrics.memory,
memoryUsed: memUsed.value,
memoryTotal: memTotal.value,
memoryUnit: memTotal.unit,
uptime: 0
},
players: { online: 0, max: null, list: [] },
hasRcon: !!server.rconPassword
};
}
const wantsPlayers = server.rconPassword || server.type === 'hytale' || server.type === 'terraria' || server.type === 'spaceengineers';
const [status, metrics, players, playerList, processUptime] = await Promise.all([
getServerStatus(server),
getCurrentMetrics(server.id).catch(() => ({
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
})),
wantsPlayers ? getPlayers(server).catch(() => ({ online: 0, max: null })) : { online: 0, max: null },
wantsPlayers ? getPlayerList(server).catch(() => ({ players: [] })) : { players: [] },
getProcessUptime(server).catch(() => 0)
]);
const memTotal = formatBytes(metrics.memoryTotal);
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
const shutdownSettings = getAutoShutdownSettings(server.id);
const emptySince = getEmptySince(server.id);
return {
id: server.id,
name: server.name,
type: server.type,
status,
running: status === 'online',
metrics: {
cpu: metrics.cpu,
cpuCores: metrics.cpuCores,
memory: metrics.memory,
memoryUsed: memUsed.value,
memoryTotal: memTotal.value,
memoryUnit: memTotal.unit,
uptime: processUptime
},
players: {
...players,
list: playerList.players
},
hasRcon: !!server.rconPassword,
autoShutdown: {
enabled: shutdownSettings?.enabled === 1 || false,
timeoutMinutes: shutdownSettings?.timeout_minutes || 15,
emptySinceMinutes: emptySince
}
};
}
async function getServerPayload(server) {
const cached = serverPayloadCache.get(server.id);
if (cached && Date.now() - cached.time < SERVER_STATUS_TTL) {
return cached.data;
}
const inflight = serverPayloadInflight.get(server.id);
if (inflight) return inflight;
const p = computeServerPayload(server)
.then((data) => {
serverPayloadCache.set(server.id, { time: Date.now(), data });
return data;
})
.finally(() => {
serverPayloadInflight.delete(server.id);
});
serverPayloadInflight.set(server.id, p);
return p;
}
// ============ FACTORIO ROUTES ============
// Factorio: List saves
router.get("/factorio/saves", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "factorio");
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
const saves = await listFactorioSaves(server);
res.json({ saves });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Get presets and default settings
router.get("/factorio/presets", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const presets = getPresetNames();
const defaultSettings = getDefaultMapGenSettings();
res.json({ presets, defaultSettings });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Get preset by name
router.get("/factorio/presets/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const preset = getPreset(req.params.name);
res.json({ settings: preset });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: List templates
router.get("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const templates = getFactorioTemplates();
res.json({ templates: templates.map(t => ({ ...t, settings: JSON.parse(t.settings) })) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Create template
router.post("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const { name, settings } = req.body;
if (!name || !settings) {
return res.status(400).json({ error: "Name and settings required" });
}
const id = createFactorioTemplate(name, settings, req.user.id);
res.json({ id, message: "Template created" });
} catch (err) {
if (err.message.includes("UNIQUE constraint")) {
return res.status(400).json({ error: "Template name already exists" });
}
res.status(500).json({ error: err.message });
}
});
// Factorio: Delete template
router.delete("/factorio/templates/:id", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const result = deleteFactorioTemplate(parseInt(req.params.id));
if (result.changes === 0) {
return res.status(404).json({ error: "Template not found" });
}
res.json({ message: "Template deleted" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Create new world
router.post("/factorio/create-world", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const { saveName, settings } = req.body;
if (!saveName) {
return res.status(400).json({ error: "Save name required" });
}
const config = loadConfig();
const server = config.servers.find(s => s.type === "factorio");
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
const finalSettings = settings || getDefaultMapGenSettings();
await createFactorioWorld(server, saveName, finalSettings);
// Save settings to database for later reference
saveFactorioWorldSettings(saveName, finalSettings, req.user.id);
logActivity(req.user.id, req.user.username, 'factorio_world_create', 'factorio', saveName, req.user.discordId, req.user.avatar);
res.json({ message: "World created", saveName });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Delete save
router.delete("/factorio/saves/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "factorio");
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
await deleteFactorioSave(server, req.params.name);
// Also delete stored settings if they exist
deleteFactorioWorldSettings(req.params.name);
logActivity(req.user.id, req.user.username, 'factorio_world_delete', 'factorio', req.params.name, req.user.discordId, req.user.avatar);
res.json({ message: "Save deleted" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Get world settings
router.get("/factorio/saves/:name/settings", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const settings = getFactorioWorldSettings(req.params.name);
if (!settings) {
return res.json({
legacy: true,
message: "This is a legacy world created before settings tracking was implemented"
});
}
res.json({
legacy: false,
settings: JSON.parse(settings.settings),
createdBy: settings.created_by,
createdAt: settings.created_at
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Get current/default save
router.get("/factorio/current-save", authenticateToken, async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "factorio");
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
const result = await getFactorioCurrentSave(server);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ SPACE ENGINEERS WORLD ROUTES ============
function getSEServer() {
return loadConfig().servers.find(s => s.type === "spaceengineers");
}
// SE: List worlds (active + library)
router.get("/spaceengineers/worlds", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const server = getSEServer();
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
const data = await listSpaceEngineersWorlds(server);
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message, message: err.message });
}
});
// SE: List built-in templates
router.get("/spaceengineers/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const server = getSEServer();
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
const templates = await listSpaceEngineersTemplates(server);
res.json({ templates });
} catch (err) {
res.status(500).json({ error: err.message, message: err.message });
}
});
// SE: Create new world from a template
router.post("/spaceengineers/worlds", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const { name, template } = req.body || {};
if (!name || !template) return res.status(400).json({ error: "name and template required", message: "name und template erforderlich" });
const server = getSEServer();
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
const result = await createSpaceEngineersWorldFromTemplate(server, name, template);
logActivity(req.user.id, req.user.username, 'se_world_create', server.id, `${result.name} (Vorlage: ${template})`, req.user.discordId, req.user.avatar);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message, message: err.message });
}
});
// SE: Clone the active world
router.post("/spaceengineers/worlds/clone", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const { name } = req.body || {};
if (!name) return res.status(400).json({ error: "name required", message: "name erforderlich" });
const server = getSEServer();
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
const status = await getServerStatus(server);
if (status === "online") return res.status(409).json({ error: "Server muss gestoppt sein zum Klonen", message: "Server muss gestoppt sein zum Klonen" });
const result = await cloneSpaceEngineersWorld(server, name);
logActivity(req.user.id, req.user.username, 'se_world_clone', server.id, result.name, req.user.discordId, req.user.avatar);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message, message: err.message });
}
});
// SE: Switch the active world (server must be stopped)
router.post("/spaceengineers/worlds/:name/activate", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const server = getSEServer();
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
const status = await getServerStatus(server);
if (status === "online") return res.status(409).json({ error: "Server muss gestoppt sein zum Wechseln der Welt", message: "Server muss gestoppt sein zum Wechseln der Welt" });
const result = await switchSpaceEngineersWorld(server, req.params.name);
logActivity(req.user.id, req.user.username, 'se_world_activate', server.id, result.active, req.user.discordId, req.user.avatar);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message, message: err.message });
}
});
// SE: Rename a world
router.patch("/spaceengineers/worlds/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const { newName } = req.body || {};
if (!newName) return res.status(400).json({ error: "newName required", message: "newName erforderlich" });
const server = getSEServer();
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
const result = await renameSpaceEngineersWorld(server, req.params.name, newName);
logActivity(req.user.id, req.user.username, 'se_world_rename', server.id, `${req.params.name} -> ${result.name}`, req.user.discordId, req.user.avatar);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message, message: err.message });
}
});
// SE: Delete a world (not the active one)
router.delete("/spaceengineers/worlds/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const server = getSEServer();
if (!server) return res.status(404).json({ error: "Space Engineers server not configured" });
const result = await deleteSpaceEngineersWorld(server, req.params.name);
logActivity(req.user.id, req.user.username, 'se_world_delete', server.id, result.name, req.user.discordId, req.user.avatar);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message, message: err.message });
}
});
// ============ ZOMBOID CONFIG ROUTES ============
// Zomboid: List config files
router.get("/zomboid/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "zomboid");
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
}
const files = await listZomboidConfigs(server);
res.json({ files });
} catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message });
}
});
// Zomboid: Read config file
router.get("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "zomboid");
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
const content = await readZomboidConfig(server, req.params.filename);
res.json({ filename: req.params.filename, content });
} catch (err) {
if (err.message === "File not allowed") {
return res.status(403).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
// Zomboid: Write config file
router.put("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "zomboid");
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
const { content } = req.body;
if (content === undefined) {
return res.status(400).json({ error: "Content required" });
}
await writeZomboidConfig(server, req.params.filename, content);
logActivity(req.user.id, req.user.username, 'zomboid_config', 'zomboid', req.params.filename, req.user.discordId, req.user.avatar);
res.json({ message: "Config saved", filename: req.params.filename });
} catch (err) {
if (err.message === "File not allowed") {
return res.status(403).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
// Palworld: List config files
router.get("/palworld/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "palworld");
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
const files = await listPalworldConfigs(server);
res.json({ files });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Palworld: Read config file
router.get("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "palworld");
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
const content = await readPalworldConfig(server, req.params.filename);
res.json({ filename: req.params.filename, content });
} catch (err) {
if (err.message === "File not allowed") {
return res.status(403).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
// Palworld: Write config file
router.put("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "palworld");
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
const { content } = req.body;
if (content === undefined) {
return res.status(400).json({ error: "Content required" });
}
await writePalworldConfig(server, req.params.filename, content);
logActivity(req.user.id, req.user.username, "palworld_config", "palworld", req.params.filename, req.user.discordId, req.user.avatar);
res.json({ message: "Config saved", filename: req.params.filename });
} catch (err) {
if (err.message === "File not allowed") {
return res.status(403).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
// ============ GENERAL ROUTES ============
// Get all servers with status
router.get('/', optionalAuth, async (req, res) => {
try {
const config = loadConfig();
const servers = await Promise.all(config.servers.map((server) => getServerPayload(server)));
res.json(servers);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Activity Log (superadmin only)
router.get('/activity-log', authenticateToken, requireRole('superadmin'), (req, res) => {
try {
const limit = parseInt(req.query.limit) || 100;
const logs = getActivityLog(limit);
res.json(logs);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get all server display settings (for ServerCard)
router.get("/display-settings", optionalAuth, async (req, res) => {
try {
const settings = getAllServerDisplaySettings();
const result = {};
settings.forEach(s => {
result[s.server_id] = { address: s.address, hint: s.hint };
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ TERRARIA ROUTES ============
// Get Terraria config
router.get("/terraria/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "terraria");
if (!server) return res.status(404).json({ error: "Server not found" });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
}
const content = await readTerrariaConfig(server);
res.json({ content });
} catch (error) {
console.error("Error reading Terraria config:", error);
if (error.message.includes('unreachable') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: error.message });
}
});
// Save Terraria config
router.put("/terraria/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "terraria");
if (!server) return res.status(404).json({ error: "Server not found" });
const { content } = req.body;
if (!content) return res.status(400).json({ error: "Content required" });
await writeTerrariaConfig(server, content);
logActivity(req.user.id, req.user.username, "terraria_config", "terraria", "serverconfig.txt", req.user.discordId, req.user.avatar);
res.json({ success: true });
} catch (error) {
console.error("Error writing Terraria config:", error);
res.status(500).json({ error: error.message });
}
});
// ============ OPENTTD ROUTES ============
// Get OpenTTD config
router.get("/openttd/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "openttd");
if (!server) return res.status(404).json({ error: "Server not found" });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
}
const content = await readOpenTTDConfig(server);
res.json({ content });
} catch (error) {
console.error("Error reading OpenTTD config:", error);
if (error.message.includes('unreachable') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: error.message });
}
});
// Save OpenTTD config
router.put("/openttd/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "openttd");
if (!server) return res.status(404).json({ error: "Server not found" });
const { content } = req.body;
if (!content) return res.status(400).json({ error: "Content required" });
await writeOpenTTDConfig(server, content);
logActivity(req.user.id, req.user.username, "openttd_config", "openttd", "openttd.cfg", req.user.discordId, req.user.avatar);
res.json({ success: true });
} catch (error) {
console.error("Error writing OpenTTD config:", error);
res.status(500).json({ error: error.message });
}
});
// ============ HYTALE ROUTES ============
// Get Hytale config
router.get("/hytale/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "hytale");
if (!server) return res.status(404).json({ error: "Server not found" });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
}
const content = await readHytaleConfig(server);
res.json({ content });
} catch (error) {
console.error("Error reading Hytale config:", error);
if (error.message.includes('unreachable') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: error.message });
}
});
// Save Hytale config
router.put("/hytale/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "hytale");
if (!server) return res.status(404).json({ error: "Server not found" });
const { content } = req.body;
if (!content) return res.status(400).json({ error: "Content required" });
await writeHytaleConfig(server, content);
logActivity(req.user.id, req.user.username, "hytale_config", "hytale", "config.json", req.user.discordId, req.user.avatar);
res.json({ success: true });
} catch (error) {
console.error("Error writing Hytale config:", error);
res.status(500).json({ error: error.message });
}
});
// Get single server (guests not allowed). Shares the same short-TTL cache as the
// dashboard list, so the detail page costs one server's worth of work — not the
// whole fleet — and a user with both pages open reuses the same computation.
router.get('/:id', authenticateToken, rejectGuest, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
try {
const payload = await getServerPayload(server);
res.json(payload);
} catch (err) {
console.error(`Error fetching server ${req.params.id}:`, err.message);
res.status(500).json({ error: err.message });
}
});
// Get server version info (build id + last update timestamp)
router.get('/:id/version', authenticateToken, rejectGuest, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (!supportsVersionInfo(server.type)) {
return res.json({ supported: false });
}
if (isHostFailed(server.host, server.sshUser)) {
return res.json({ supported: true, error: 'Host unreachable' });
}
try {
const info = await getVersionInfo(server);
if (!info) {
return res.json({ supported: true, error: 'Version konnte nicht ermittelt werden' });
}
res.json({ supported: true, ...info });
} catch (err) {
console.error(`[Version] Failed for ${server.id}:`, err.message);
res.status(500).json({ supported: true, error: err.message });
}
});
// Get metrics history from Prometheus (guests not allowed)
router.get('/:id/metrics/history', authenticateToken, rejectGuest, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
const range = req.query.range || '1h';
const validRanges = ['15m', '1h', '6h', '24h'];
if (!validRanges.includes(range)) {
return res.status(400).json({ error: 'Invalid range. Valid: 15m, 1h, 6h, 24h' });
}
try {
const history = await getServerMetricsHistory(server.id, range);
res.json(history);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get player list
router.get('/:id/players', authenticateToken, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (!server.rconPassword) {
return res.status(400).json({ error: 'RCON not configured for this server' });
}
try {
const [count, list] = await Promise.all([
getPlayers(server),
getPlayerList(server)
]);
res.json({ ...count, list: list.players });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get console logs (moderator+)
router.get('/:id/logs', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
try {
const lines = parseInt(req.query.lines) || 100;
const logs = await getConsoleLog(server, lines);
res.json({ logs });
} catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message });
}
});
// Power actions (moderator+)
router.post('/:id/start', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
try {
const { save } = req.body || {};
await startServer(server, { save });
invalidateServerPayload(server.id); // force a fresh status on the next poll
logActivity(req.user.id, req.user.username, 'server_start', server.id, save ? 'Save: ' + save : null, req.user.discordId, req.user.avatar);
res.json({ message: 'Server starting' });
} catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message });
}
});
router.post('/:id/stop', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
try {
await stopServer(server);
invalidateServerPayload(server.id); // force a fresh status on the next poll
logActivity(req.user.id, req.user.username, 'server_stop', server.id, null, req.user.discordId, req.user.avatar);
res.json({ message: 'Server stopping' });
} catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message });
}
});
router.post('/:id/restart', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
try {
await restartServer(server);
invalidateServerPayload(server.id); // force a fresh status on the next poll
logActivity(req.user.id, req.user.username, 'server_restart', server.id, null, req.user.discordId, req.user.avatar);
res.json({ message: 'Server restarting' });
} catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message });
}
});
// ============ SERVER UPDATE ROUTES ============
// Check for server updates (moderator+)
router.get('/:id/update', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
// Check if this server type supports updates
if (!supportsUpdate(server.type)) {
return res.json({
supported: false,
hasUpdate: false,
message: 'Updates für diesen Servertyp nicht unterstützt'
});
}
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
try {
const result = await checkForUpdate(server);
res.json(result);
} catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message });
}
});
// Perform server update (moderator+)
router.post('/:id/update', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (!supportsUpdate(server.type)) {
return res.status(400).json({ error: 'Updates für diesen Servertyp nicht unterstützt' });
}
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
// Check if server is running
try {
const status = await getServerStatus(server);
if (status === 'online' || status === 'starting') {
return res.status(400).json({ error: 'Server muss gestoppt sein um zu updaten' });
}
} catch (err) {
// Continue if status check fails
}
try {
const sendDiscord = req.body?.sendDiscord !== false; // Default true
const result = await performUpdate(server, sendDiscord);
invalidateVersionCache(server.id);
logActivity(req.user.id, req.user.username, 'server_update', server.id, result.newVersion || 'latest', req.user.discordId, req.user.avatar);
res.json(result);
} catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message });
}
});
// Get whitelist (with server-side caching, guests not allowed)
router.get('/:id/whitelist', authenticateToken, rejectGuest, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (!server.rconPassword) {
return res.json({ players: [], cached: false });
}
try {
const response = await sendRconCommand(server, 'whitelist list');
const match = response.trim().match(/:\s*(.+)$/);
let players = [];
if (match && match[1]) {
players = match[1].split(',').map(p => p.trim()).filter(p => p.length > 0);
}
setCachedWhitelist(server.id, players);
res.json({ players, cached: false });
} catch (err) {
const players = getCachedWhitelist(server.id);
res.json({ players, cached: true });
}
});
// RCON command (moderator+)
router.post('/:id/rcon', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (!server.rconPassword) {
return res.status(400).json({ error: 'RCON not configured for this server' });
}
const { command } = req.body;
if (!command) {
return res.status(400).json({ error: 'Command required' });
}
try {
const response = await sendRconCommand(server, command);
logActivity(req.user.id, req.user.username, 'rcon_command', server.id, command, req.user.discordId, req.user.avatar);
if (command.startsWith("whitelist ")) {
try {
const listResponse = await sendRconCommand(server, "whitelist list");
const match = listResponse.trim().match(/:\s*(.+)$/);
let players = [];
if (match && match[1]) {
players = match[1].split(",").map(p => p.trim()).filter(p => p.length > 0);
}
setCachedWhitelist(server.id, players);
} catch (e) { /* ignore */ }
}
res.json({ response });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Initialize auto-shutdown settings table
initAutoShutdownSettings();
// ============ AUTO-SHUTDOWN ROUTES ============
// Get auto-shutdown settings for a server
router.get('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
const settings = getAutoShutdownSettings(req.params.id);
const emptySince = getEmptySince(req.params.id);
res.json({
enabled: settings?.enabled === 1 || false,
timeoutMinutes: settings?.timeout_minutes || 15,
emptySinceMinutes: emptySince
});
});
// Update auto-shutdown settings for a server
router.put('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
const { enabled, timeoutMinutes } = req.body;
const timeout = Math.max(1, Math.min(1440, timeoutMinutes || 15));
setAutoShutdownSettings(req.params.id, enabled, timeout);
logActivity(req.user.id, req.user.username, 'autoshutdown_config', req.params.id, 'Enabled: ' + enabled + ', Timeout: ' + timeout + ' min', req.user.discordId, req.user.avatar);
console.log('[AutoShutdown] Settings updated for ' + req.params.id + ': enabled=' + enabled + ', timeout=' + timeout + 'min');
res.json({ message: 'Auto-shutdown settings updated', enabled, timeoutMinutes: timeout });
});
// Get display settings for a specific server
router.get("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
try {
const settings = getServerDisplaySettings(req.params.id);
res.json(settings || { server_id: req.params.id, address: "", hint: "" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Update display settings for a server (superadmin only)
router.put("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
const { address, hint } = req.body;
try {
setServerDisplaySettings(req.params.id, address || "", hint || "");
res.json({ message: "Display settings updated", address, hint });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ DISCORD UPDATE ROUTES ============
// Send update to all Discord guilds (superadmin only)
router.post("/discord/send-update", authenticateToken, requireRole("superadmin"), async (req, res) => {
const { title, description, color, serverType } = req.body;
if (!title || !description) {
return res.status(400).json({ error: "Title and description required" });
}
try {
const serverLogos = {
minecraft: 'https://gsm.zeasy.dev/minecraft.png',
factorio: 'https://gsm.zeasy.dev/factorio.png',
zomboid: 'https://gsm.zeasy.dev/zomboid.png',
vrising: 'https://gsm.zeasy.dev/vrising.png',
palworld: 'https://gsm.zeasy.dev/palworld.png',
terraria: 'https://gsm.zeasy.dev/terraria.png',
openttd: 'https://gsm.zeasy.dev/openttd.png',
hytale: 'https://gsm.zeasy.dev/hytale.png'
};
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(color || 0x5865F2)
.setTimestamp();
if (serverLogos[serverType]) {
embed.setThumbnail(serverLogos[serverType]);
}
await sendUpdateToAllGuilds(embed);
logActivity(req.user.id, req.user.username, 'discord_update', serverType || 'general', title, req.user.discordId, req.user.avatar);
res.json({ message: "Update sent to all Discord guilds" });
} catch (err) {
console.error('[Discord] Error sending update:', err);
res.status(500).json({ error: err.message });
}
});
// Internal route for sending updates (localhost only, no auth)
router.post("/discord/internal-update", async (req, res) => {
const clientIp = req.ip || req.connection.remoteAddress;
if (clientIp !== '127.0.0.1' && clientIp !== '::1' && clientIp !== '::ffff:127.0.0.1') {
return res.status(403).json({ error: "Forbidden - localhost only" });
}
const { title, description, color, serverType } = req.body;
if (!title || !description) {
return res.status(400).json({ error: "Title and description required" });
}
try {
const serverLogos = {
minecraft: 'https://gsm.zeasy.dev/minecraft.png',
factorio: 'https://gsm.zeasy.dev/factorio.png',
zomboid: 'https://gsm.zeasy.dev/zomboid.png',
vrising: 'https://gsm.zeasy.dev/vrising.png',
palworld: 'https://gsm.zeasy.dev/palworld.png',
terraria: 'https://gsm.zeasy.dev/terraria.png',
openttd: 'https://gsm.zeasy.dev/openttd.png',
hytale: 'https://gsm.zeasy.dev/hytale.png'
};
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(color || 0x5865F2)
.setTimestamp();
if (serverLogos[serverType]) {
embed.setThumbnail(serverLogos[serverType]);
}
await sendUpdateToAllGuilds(embed);
res.json({ message: "Update sent" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;