diff --git a/gsm-backend/routes/servers.js b/gsm-backend/routes/servers.js index 63012d6..6e4af0e 100644 --- a/gsm-backend/routes/servers.js +++ b/gsm-backend/routes/servers.js @@ -5,6 +5,7 @@ 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 } from '../services/ssh.js'; +import { supportsUpdate, checkForUpdate, performUpdate } from '../services/serverUpdates.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'; @@ -749,6 +750,75 @@ router.post('/:id/restart', authenticateToken, requireRole('moderator'), async ( } }); +// ============ 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); + 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(); diff --git a/gsm-backend/services/serverUpdates.js b/gsm-backend/services/serverUpdates.js new file mode 100644 index 0000000..ab48f49 --- /dev/null +++ b/gsm-backend/services/serverUpdates.js @@ -0,0 +1,309 @@ +import { NodeSSH } from "node-ssh"; +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { EmbedBuilder } from "discord.js"; +import { sendUpdateToAllGuilds } from "./discordBot.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadConfig() { + return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8")); +} + +// Server logos for Discord embeds +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' +}; + +// SSH connection helper (reuse existing pattern) +const sshConnections = new Map(); +const SSH_TIMEOUT = 30000; // Longer timeout for update operations + +async function getConnection(host, username = "root") { + const key = username + "@" + host; + + if (sshConnections.has(key)) { + const conn = sshConnections.get(key); + if (conn.isConnected()) return conn; + sshConnections.delete(key); + } + + const ssh = new NodeSSH(); + await ssh.connect({ + host, + username, + privateKeyPath: "/root/.ssh/id_ed25519", + readyTimeout: SSH_TIMEOUT + }); + sshConnections.set(key, ssh); + return ssh; +} + +/** + * Send Discord notification about server update + */ +async function sendUpdateNotification(server, updateInfo) { + try { + const embed = new EmbedBuilder() + .setTitle(`${server.name} wurde aktualisiert!`) + .setDescription(updateInfo.message || "Der Server wurde erfolgreich aktualisiert.") + .setColor(0x00FF00) // Green for success + .setTimestamp(); + + if (serverLogos[server.type]) { + embed.setThumbnail(serverLogos[server.type]); + } + + if (updateInfo.oldVersion && updateInfo.newVersion) { + embed.addFields( + { name: "Alte Version", value: updateInfo.oldVersion, inline: true }, + { name: "Neue Version", value: updateInfo.newVersion, inline: true } + ); + } + + await sendUpdateToAllGuilds(embed); + console.log(`[Update] Discord notification sent for ${server.name}`); + } catch (err) { + console.error(`[Update] Failed to send Discord notification:`, err.message); + // Don't throw - update was successful, just notification failed + } +} + +// ============================================================================= +// Update Handler Registry - Add new game handlers here +// ============================================================================= + +const updateHandlers = { + // Factorio (Docker-based) + factorio: { + supportsUpdate: true, + gameName: "Factorio", + + async checkForUpdate(server) { + const ssh = await getConnection(server.host, server.sshUser); + + // Get current image ID + const currentResult = await ssh.execCommand( + `docker inspect --format='{{.Image}}' ${server.containerName} 2>/dev/null` + ); + const currentImageId = currentResult.stdout.trim(); + + if (!currentImageId) { + return { hasUpdate: false, error: "Container not found" }; + } + + // Pull latest image and check if different + console.log("[Update] Checking for Factorio updates..."); + const pullResult = await ssh.execCommand( + "docker pull factoriotools/factorio:latest 2>&1", + { execOptions: { timeout: 120000 } } + ); + + // Check if image was updated + const isUpToDate = pullResult.stdout.includes("Image is up to date") || + pullResult.stdout.includes("Status: Image is up to date"); + + if (isUpToDate) { + return { + hasUpdate: false, + currentVersion: "latest", + message: "Server ist auf dem neuesten Stand" + }; + } + + // Get new image ID + const newResult = await ssh.execCommand( + "docker inspect --format='{{.Id}}' factoriotools/factorio:latest 2>/dev/null" + ); + const newImageId = newResult.stdout.trim(); + + // Compare image IDs + const hasUpdate = currentImageId !== newImageId && newImageId.length > 0; + + return { + hasUpdate, + currentVersion: currentImageId.substring(7, 19), + newVersion: newImageId.substring(7, 19), + message: hasUpdate ? "Neues Update verfügbar!" : "Server ist auf dem neuesten Stand" + }; + }, + + async performUpdate(server) { + const ssh = await getConnection(server.host, server.sshUser); + + // Verify server is stopped + const statusResult = await ssh.execCommand( + `docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null` + ); + if (statusResult.stdout.trim() === "running") { + throw new Error("Server muss gestoppt sein um zu updaten"); + } + + // Get current image ID for version comparison + const oldImageResult = await ssh.execCommand( + `docker inspect --format='{{.Image}}' ${server.containerName} 2>/dev/null` + ); + const oldImageId = oldImageResult.stdout.trim().substring(7, 19); + + console.log("[Update] Starting Factorio update..."); + + // Pull latest image + const pullResult = await ssh.execCommand( + "docker pull factoriotools/factorio:latest 2>&1", + { execOptions: { timeout: 180000 } } + ); + console.log("[Update] Pull result:", pullResult.stdout); + + // Get current container config for recreation + const configResult = await ssh.execCommand(` + docker inspect ${server.containerName} --format='{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null + `); + + // Parse environment variables + const envVars = {}; + configResult.stdout.split('\n').forEach(line => { + const [key, ...valueParts] = line.split('='); + if (key && valueParts.length > 0) { + envVars[key] = valueParts.join('='); + } + }); + + // Remove old container + console.log("[Update] Removing old container..."); + await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`); + await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`); + + // Build environment string + const envString = Object.entries(envVars) + .filter(([k]) => ['SAVE_NAME', 'LOAD_LATEST_SAVE', 'TZ', 'PUID', 'PGID'].includes(k)) + .map(([k, v]) => `-e ${k}="${v}"`) + .join(' '); + + // Create new container with updated image + console.log("[Update] Creating new container with updated image..."); + const createResult = await ssh.execCommand(` + docker create \ + --name ${server.containerName} \ + -p 34197:34197/udp \ + -p 27015:27015/tcp \ + -v /srv/docker/factorio/data:/factorio \ + ${envString || '-e TZ=Europe/Berlin -e PUID=845 -e PGID=845'} \ + --restart=unless-stopped \ + factoriotools/factorio:latest + `); + + if (createResult.code !== 0) { + throw new Error("Container creation failed: " + createResult.stderr); + } + + // Get new image ID + const newImageResult = await ssh.execCommand( + "docker inspect --format='{{.Id}}' factoriotools/factorio:latest 2>/dev/null" + ); + const newImageId = newImageResult.stdout.trim().substring(7, 19); + + // Clean up old images + console.log("[Update] Cleaning up old images..."); + await ssh.execCommand("docker image prune -f 2>/dev/null || true"); + + return { + success: true, + message: "Update erfolgreich! Server kann jetzt gestartet werden.", + oldVersion: oldImageId, + newVersion: newImageId + }; + } + }, + + // Add more game handlers here in the future + // Example template for other games: + /* + minecraft: { + supportsUpdate: true, + gameName: "Minecraft", + async checkForUpdate(server) { + // Implementation for Minecraft updates + return { hasUpdate: false, message: "Not implemented" }; + }, + async performUpdate(server) { + // Implementation for Minecraft updates + return { success: false, message: "Not implemented" }; + } + }, + */ +}; + +// ============================================================================= +// Public API +// ============================================================================= + +/** + * Check if a server type supports updates + */ +export function supportsUpdate(serverType) { + return updateHandlers[serverType]?.supportsUpdate === true; +} + +/** + * Get list of all server types that support updates + */ +export function getUpdatableServerTypes() { + return Object.entries(updateHandlers) + .filter(([_, handler]) => handler.supportsUpdate) + .map(([type]) => type); +} + +/** + * Check if an update is available for a server + */ +export async function checkForUpdate(server) { + const handler = updateHandlers[server.type]; + + if (!handler || !handler.supportsUpdate) { + return { + hasUpdate: false, + supported: false, + message: "Updates für diesen Servertyp nicht unterstützt" + }; + } + + try { + const result = await handler.checkForUpdate(server); + return { ...result, supported: true }; + } catch (err) { + console.error(`[Update] Check failed for ${server.id}:`, err.message); + return { + hasUpdate: false, + supported: true, + error: err.message + }; + } +} + +/** + * Perform update for a server (with automatic Discord notification) + */ +export async function performUpdate(server, sendDiscordNotification = true) { + const handler = updateHandlers[server.type]; + + if (!handler || !handler.supportsUpdate) { + throw new Error("Updates für diesen Servertyp nicht unterstützt"); + } + + const result = await handler.performUpdate(server); + + // Send Discord notification on successful update + if (result.success && sendDiscordNotification) { + await sendUpdateNotification(server, result); + } + + return result; +} diff --git a/gsm-frontend/src/api.js b/gsm-frontend/src/api.js index 30a8bad..4fa61c5 100644 --- a/gsm-frontend/src/api.js +++ b/gsm-frontend/src/api.js @@ -332,5 +332,20 @@ export async function sendDiscordUpdate(token, title, description, serverType, c }) } +// Server Updates +export async function checkServerUpdate(token, serverId) { + return fetchAPI(`/servers/${serverId}/update`, { + headers: { Authorization: `Bearer ${token}` }, + }) +} + +export async function performServerUpdate(token, serverId, sendDiscord = true) { + return fetchAPI(`/servers/${serverId}/update`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ sendDiscord }), + }) +} + // Alias for backwards compatibility export const setDisplaySettings = saveDisplaySettings diff --git a/gsm-frontend/src/components/ServerUpdateButton.jsx b/gsm-frontend/src/components/ServerUpdateButton.jsx new file mode 100644 index 0000000..1544a32 --- /dev/null +++ b/gsm-frontend/src/components/ServerUpdateButton.jsx @@ -0,0 +1,130 @@ +import { useState, useEffect } from 'react' +import { checkServerUpdate, performServerUpdate } from '../api' +import ConfirmModal from './ConfirmModal' + +export default function ServerUpdateButton({ server, token, onUpdateComplete }) { + const [checking, setChecking] = useState(false) + const [updating, setUpdating] = useState(false) + const [updateInfo, setUpdateInfo] = useState(null) + const [error, setError] = useState(null) + const [showConfirm, setShowConfirm] = useState(false) + + const isServerRunning = server.status === 'online' || server.status === 'starting' || server.status === 'stopping' || server.running + + // Check for updates when component mounts or server status changes + useEffect(() => { + if (!isServerRunning && token) { + handleCheckUpdate() + } + }, [server.status, server.running]) + + const handleCheckUpdate = async () => { + setChecking(true) + setError(null) + try { + const result = await checkServerUpdate(token, server.id) + setUpdateInfo(result) + } catch (err) { + setError(err.message) + setUpdateInfo(null) + } finally { + setChecking(false) + } + } + + const handleUpdate = async () => { + setShowConfirm(false) + setUpdating(true) + setError(null) + try { + const result = await performServerUpdate(token, server.id, true) + setUpdateInfo({ hasUpdate: false, message: result.message }) + if (onUpdateComplete) { + onUpdateComplete(result) + } + } catch (err) { + setError(err.message) + } finally { + setUpdating(false) + } + } + + // Don't show if server type doesn't support updates + if (updateInfo && !updateInfo.supported) { + return null + } + + return ( +
+ Prüfe auf neue Versionen und aktualisiere den Server +
+