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' }; // Steam App IDs const STEAM_APP_IDS = { vrising: 1829350, palworld: 2394010, zomboid: 380870, terraria: 1281930 // tModLoader }; // 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 } } // ============================================================================= // SteamCMD Helper Functions // ============================================================================= /** * Find SteamCMD executable on the server */ async function findSteamCmd(ssh, hints = []) { // Try hints first for (const hint of hints) { const result = await ssh.execCommand(`test -f "${hint}" && echo "${hint}"`); if (result.stdout.trim()) { return result.stdout.trim(); } } // Common locations to check const commonPaths = [ "/usr/games/steamcmd", "/home/steam/steamcmd/steamcmd.sh", "/home/steam/Steam/steamcmd.sh", "$HOME/steamcmd/steamcmd.sh", "$HOME/Steam/steamcmd.sh" ]; for (const path of commonPaths) { const result = await ssh.execCommand(`test -f ${path} && echo "${path}"`); if (result.stdout.trim()) { return result.stdout.trim(); } } // Try which as last resort const whichResult = await ssh.execCommand("which steamcmd 2>/dev/null"); if (whichResult.stdout.trim()) { return whichResult.stdout.trim(); } return null; } /** * Get the current installed build ID from Steam appmanifest */ async function getSteamBuildId(ssh, installDir, appId) { const manifestPath = `${installDir}/steamapps/appmanifest_${appId}.acf`; const result = await ssh.execCommand(`grep -oP '"buildid"\\s*"\\K[^"]+' ${manifestPath} 2>/dev/null`); return result.stdout.trim() || null; } /** * Get the latest available build ID from Steam */ async function getLatestSteamBuildId(ssh, appId, steamcmdPath = "steamcmd") { // Query Steam for app info const result = await ssh.execCommand( `${steamcmdPath} +login anonymous +app_info_update 1 +app_info_print ${appId} +quit 2>/dev/null | grep -A2 '"public"' | grep '"buildid"' | head -1 | grep -oP '"buildid"\\s*"\\K[^"]+'`, { execOptions: { timeout: 60000 } } ); return result.stdout.trim() || null; } /** * Create a generic SteamCMD update handler */ function createSteamHandler(config) { const { appId, installDir, steamcmdHints = [], gameName } = config; return { supportsUpdate: true, gameName, async checkForUpdate(server) { const ssh = await getConnection(server.host, server.sshUser); const actualInstallDir = installDir || server.workDir; console.log(`[Update] Checking for ${gameName} updates...`); // Find SteamCMD const steamcmdPath = await findSteamCmd(ssh, steamcmdHints); if (!steamcmdPath) { return { hasUpdate: false, error: "SteamCMD nicht gefunden auf dem Server" }; } console.log(`[Update] Found SteamCMD at: ${steamcmdPath}`); // Get current build ID const currentBuildId = await getSteamBuildId(ssh, actualInstallDir, appId); if (!currentBuildId) { return { hasUpdate: false, error: "Konnte aktuelle Version nicht ermitteln (appmanifest nicht gefunden)" }; } // Get latest build ID from Steam const latestBuildId = await getLatestSteamBuildId(ssh, appId, steamcmdPath); if (!latestBuildId) { return { hasUpdate: false, error: "Konnte Steam-Version nicht abrufen" }; } const hasUpdate = currentBuildId !== latestBuildId; return { hasUpdate, currentVersion: currentBuildId, newVersion: latestBuildId, message: hasUpdate ? "Neues Update verfügbar!" : "Server ist auf dem neuesten Stand" }; }, async performUpdate(server) { const ssh = await getConnection(server.host, server.sshUser); const actualInstallDir = installDir || server.workDir; // Find SteamCMD const steamcmdPath = await findSteamCmd(ssh, steamcmdHints); if (!steamcmdPath) { throw new Error("SteamCMD nicht gefunden auf dem Server"); } // Get old build ID const oldBuildId = await getSteamBuildId(ssh, actualInstallDir, appId); console.log(`[Update] Starting ${gameName} update with ${steamcmdPath}...`); // Run SteamCMD update const updateResult = await ssh.execCommand( `${steamcmdPath} +login anonymous +force_install_dir "${actualInstallDir}" +app_update ${appId} validate +quit`, { execOptions: { timeout: 600000 } } // 10 min timeout for large updates ); console.log(`[Update] SteamCMD output:`, updateResult.stdout); if (updateResult.code !== 0 && !updateResult.stdout.includes("Success")) { throw new Error("Update fehlgeschlagen: " + (updateResult.stderr || "Unbekannter Fehler")); } // Get new build ID const newBuildId = await getSteamBuildId(ssh, actualInstallDir, appId); return { success: true, message: "Update erfolgreich! Server kann jetzt gestartet werden.", oldVersion: oldBuildId || "unbekannt", newVersion: newBuildId || "aktuell" }; } }; } // ============================================================================= // Update Handler Registry // ============================================================================= 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 }; } }, // V Rising (SteamCMD) - runs as root, steam user has steamcmd vrising: createSteamHandler({ appId: STEAM_APP_IDS.vrising, installDir: "/home/steam/vrising", steamcmdHints: ["/home/steam/steamcmd/steamcmd.sh", "/home/steam/Steam/steamcmd.sh"], gameName: "V Rising" }), // Palworld (SteamCMD) - runs as root palworld: createSteamHandler({ appId: STEAM_APP_IDS.palworld, installDir: "/opt/palworld", steamcmdHints: ["/usr/games/steamcmd", "/home/steam/steamcmd/steamcmd.sh"], gameName: "Palworld" }), // Project Zomboid (SteamCMD) - runs as pzuser zomboid: createSteamHandler({ appId: STEAM_APP_IDS.zomboid, installDir: "/opt/pzserver", steamcmdHints: ["/home/pzuser/steamcmd/steamcmd.sh", "/opt/steamcmd/steamcmd.sh"], gameName: "Project Zomboid" }), // Terraria / tModLoader (SteamCMD) - runs as terraria user terraria: createSteamHandler({ appId: STEAM_APP_IDS.terraria, installDir: "/home/terraria/tModLoader", steamcmdHints: ["/home/terraria/steamcmd/steamcmd.sh", "/home/terraria/Steam/steamcmd.sh"], gameName: "Terraria (tModLoader)" }), // Minecraft Biohazard - Manual update required (modpack) minecraft: { supportsUpdate: false, // Modpacks need manual update gameName: "Biohazard: Project Genesis" }, // OpenTTD - Manual update required openttd: { supportsUpdate: false, gameName: "OpenTTD" }, // Hytale - Manual update required hytale: { supportsUpdate: false, gameName: "Hytale" } }; // ============================================================================= // 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; }