Files
GSM/gsm-backend/services/serverUpdates.js
Alexander Zielonka 5ab3154351
All checks were successful
Deploy GSM / deploy (push) Successful in 43s
Switch Minecraft server to FTB StoneBlock 4 (name + modpack metadata)
config.json, discordBot.js, serverUpdates.js and ServerCard.jsx updated from Biohazard to FTB StoneBlock 4. Docs (infrastructure/gsm/CLAUDE) updated and docs/minecraft-modpack-install.md added. No Discord update notification is triggered (minecraft supportsUpdate=false).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:44:09 +02:00

633 lines
22 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',
spaceengineers: 'https://gsm.zeasy.dev/spaceengineers.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)"
}),
// Space Engineers (Docker image with broken entrypoint - run SteamCMD ourselves
// in a one-shot sibling container with correct arg order: force_install_dir BEFORE login)
spaceengineers: {
supportsUpdate: true,
gameName: "Space Engineers",
async checkForUpdate(server) {
const ssh = await getConnection(server.host, server.sshUser);
const appId = 298740;
const manifestPath = `${server.workDir}/appdata/space-engineers/bins/SpaceEngineersDedicated/steamapps/appmanifest_${appId}.acf`;
const manifestResult = await ssh.execCommand(`cat ${manifestPath} 2>/dev/null`);
const manifest = manifestResult.stdout || "";
const buildidMatch = manifest.match(/"buildid"\s*"([^"]+)"/);
const targetMatch = manifest.match(/"TargetBuildID"\s*"([^"]+)"/);
if (!buildidMatch) {
return { hasUpdate: false, error: "appmanifest nicht gefunden" };
}
const currentBuildId = buildidMatch[1];
const targetBuildId = targetMatch ? targetMatch[1] : null;
// If Steam already flagged a newer build via prior login attempt, trust that
if (targetBuildId && targetBuildId !== "0" && targetBuildId !== currentBuildId) {
return {
hasUpdate: true,
currentVersion: currentBuildId,
newVersion: targetBuildId,
message: "Neues Update verfügbar!"
};
}
// Otherwise query Steam fresh via a one-shot container
console.log("[Update] Querying Steam for SE build info...");
const queryCmd = `docker run --rm --entrypoint /bin/bash mmmaxwwwell/space-engineers-dedicated-docker-linux:latest -c "runuser -l wine bash -c 'steamcmd +@sSteamCmdForcePlatformType windows +login anonymous +app_info_update 1 +app_info_print ${appId} +quit' 2>/dev/null"`;
const queryResult = await ssh.execCommand(queryCmd, { execOptions: { timeout: 120000 } });
const publicSection = queryResult.stdout.match(/"public"[\s\S]*?"buildid"\s*"([^"]+)"/);
const latestBuildId = publicSection ? publicSection[1] : null;
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 appId = 298740;
const manifestPath = `${server.workDir}/appdata/space-engineers/bins/SpaceEngineersDedicated/steamapps/appmanifest_${appId}.acf`;
const installHostDir = `${server.workDir}/appdata/space-engineers/bins/SpaceEngineersDedicated`;
const steamHostDir = `${server.workDir}/appdata/space-engineers/bins/steamcmd`;
// Verify container 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");
}
// Old build id
const oldManifest = await ssh.execCommand(`cat ${manifestPath} 2>/dev/null`);
const oldMatch = (oldManifest.stdout || "").match(/"buildid"\s*"([^"]+)"/);
const oldBuildId = oldMatch ? oldMatch[1] : null;
// One-shot container: same image + same volumes, but our own steamcmd call
// with force_install_dir BEFORE login (the upstream entrypoint has them swapped)
const buildUpdateCmd = () => [
`docker run --rm`,
`--entrypoint /bin/bash`,
`-v ${installHostDir}:/appdata/space-engineers/SpaceEngineersDedicated`,
`-v ${steamHostDir}:/home/wine/.steam`,
`mmmaxwwwell/space-engineers-dedicated-docker-linux:latest`,
`-c "runuser -l wine bash -c 'steamcmd +@sSteamCmdForcePlatformType windows +force_install_dir /appdata/space-engineers/SpaceEngineersDedicated +login anonymous +app_update ${appId} validate +quit'"`
].join(" ");
const readManifest = async () => {
const r = await ssh.execCommand(`cat ${manifestPath} 2>/dev/null`);
const text = r.stdout || "";
return {
buildid: text.match(/"buildid"\s*"([^"]+)"/)?.[1] || null,
target: text.match(/"TargetBuildID"\s*"([^"]+)"/)?.[1] || null
};
};
console.log("[Update] Starting Space Engineers SteamCMD update (validate pass)...");
let updateResult = await ssh.execCommand(buildUpdateCmd(), { execOptions: { timeout: 1200000 } });
console.log("[Update] SteamCMD output (tail):", updateResult.stdout.slice(-1500));
let manifest = await readManifest();
let stillPending = manifest.target && manifest.target !== "0" && manifest.target !== manifest.buildid;
let buildChanged = oldBuildId && manifest.buildid && oldBuildId !== manifest.buildid;
let reportedSuccess = (updateResult.stdout || "").includes("fully installed");
// Recovery: SteamCMD sometimes lands in state 0x6 with no bytes pending despite
// a TargetBuildID newer than buildid. validate alone won't dislodge it; deleting
// the appmanifest forces a clean reconciliation on the next run.
if (!buildChanged && !reportedSuccess && stillPending) {
console.log("[Update] First pass left a stuck pending update. Removing appmanifest and retrying...");
await ssh.execCommand(`rm -f ${manifestPath}`);
updateResult = await ssh.execCommand(buildUpdateCmd(), { execOptions: { timeout: 1800000 } }); // 30 min for full reinstall
console.log("[Update] SteamCMD output (retry tail):", updateResult.stdout.slice(-1500));
manifest = await readManifest();
buildChanged = oldBuildId && manifest.buildid && oldBuildId !== manifest.buildid;
reportedSuccess = (updateResult.stdout || "").includes("fully installed");
stillPending = manifest.target && manifest.target !== "0" && manifest.target !== manifest.buildid;
}
if (!buildChanged && !reportedSuccess && stillPending) {
const errLine = (updateResult.stdout || "").match(/Error![^\n]*/)?.[0] || updateResult.stderr || "SteamCMD meldet noch ausstehendes Update";
throw new Error("Update fehlgeschlagen: " + errLine);
}
const newBuildId = manifest.buildid;
return {
success: true,
message: "Update erfolgreich! Server kann jetzt gestartet werden.",
oldVersion: oldBuildId || "unbekannt",
newVersion: newBuildId || "aktuell"
};
}
},
// Minecraft FTB StoneBlock 4 - Manual update required (modpack)
minecraft: {
supportsUpdate: false, // Modpacks need manual update
gameName: "FTB StoneBlock 4"
},
// 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;
}