All checks were successful
Deploy GSM / deploy (push) Successful in 31s
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>
499 lines
16 KiB
JavaScript
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;
|
|
}
|