Add modular server update feature with Discord notifications
All checks were successful
Deploy GSM / deploy (push) Successful in 25s
All checks were successful
Deploy GSM / deploy (push) Successful in 25s
- Add serverUpdates.js service with handler registry for extensibility - Implement Factorio Docker image update (pull + container recreate) - Add GET/POST /servers/:id/update routes for check/perform - Add ServerUpdateButton component with auto-check and confirm dialog - Integrate update card in ServerDetail overview tab - Auto-send Discord notification on successful update Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
309
gsm-backend/services/serverUpdates.js
Normal file
309
gsm-backend/services/serverUpdates.js
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user