Files
GSM/gsm-backend/services/serverUpdates.js
Alexander Zielonka 99ca25c9e3
All checks were successful
Deploy GSM / deploy (push) Successful in 31s
Replace ATM10 with Biohazard: Project Genesis modpack
Switched Minecraft server from All the Mods 10 to Biohazard: Project Genesis
(Beta 0.4.5, MC 1.20.1 + Forge 47.4.0). Updated server name, Discord bot
display, frontend modpack link, and documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:27:58 +02:00

499 lines
16 KiB
JavaScript

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;
}