Add modular server update feature with Discord notifications
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:
Alexander Zielonka
2026-01-23 11:42:22 +01:00
parent ed60bc33c7
commit d840faeda9
5 changed files with 540 additions and 0 deletions

View File

@@ -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();

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

View File

@@ -332,5 +332,20 @@ export async function sendDiscordUpdate(token, title, description, serverType, c
})
}
// Server Updates
export async function checkServerUpdate(token, serverId) {
return fetchAPI(`/servers/${serverId}/update`, {
headers: { Authorization: `Bearer ${token}` },
})
}
export async function performServerUpdate(token, serverId, sendDiscord = true) {
return fetchAPI(`/servers/${serverId}/update`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ sendDiscord }),
})
}
// Alias for backwards compatibility
export const setDisplaySettings = saveDisplaySettings

View File

@@ -0,0 +1,130 @@
import { useState, useEffect } from 'react'
import { checkServerUpdate, performServerUpdate } from '../api'
import ConfirmModal from './ConfirmModal'
export default function ServerUpdateButton({ server, token, onUpdateComplete }) {
const [checking, setChecking] = useState(false)
const [updating, setUpdating] = useState(false)
const [updateInfo, setUpdateInfo] = useState(null)
const [error, setError] = useState(null)
const [showConfirm, setShowConfirm] = useState(false)
const isServerRunning = server.status === 'online' || server.status === 'starting' || server.status === 'stopping' || server.running
// Check for updates when component mounts or server status changes
useEffect(() => {
if (!isServerRunning && token) {
handleCheckUpdate()
}
}, [server.status, server.running])
const handleCheckUpdate = async () => {
setChecking(true)
setError(null)
try {
const result = await checkServerUpdate(token, server.id)
setUpdateInfo(result)
} catch (err) {
setError(err.message)
setUpdateInfo(null)
} finally {
setChecking(false)
}
}
const handleUpdate = async () => {
setShowConfirm(false)
setUpdating(true)
setError(null)
try {
const result = await performServerUpdate(token, server.id, true)
setUpdateInfo({ hasUpdate: false, message: result.message })
if (onUpdateComplete) {
onUpdateComplete(result)
}
} catch (err) {
setError(err.message)
} finally {
setUpdating(false)
}
}
// Don't show if server type doesn't support updates
if (updateInfo && !updateInfo.supported) {
return null
}
return (
<div className="flex items-center gap-3 flex-wrap">
{/* Check for Updates Button */}
<button
onClick={handleCheckUpdate}
disabled={checking || updating || isServerRunning}
className="btn btn-secondary"
title={isServerRunning ? 'Server muss gestoppt sein' : 'Auf Updates prüfen'}
>
{checking ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Prüfe...
</>
) : (
'Auf Updates prüfen'
)}
</button>
{/* Update Button - only show if update available */}
{updateInfo?.hasUpdate && (
<button
onClick={() => setShowConfirm(true)}
disabled={updating || isServerRunning}
className="btn btn-primary"
>
{updating ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Aktualisiere...
</>
) : (
'Jetzt updaten'
)}
</button>
)}
{/* Status Badge */}
{updateInfo && !checking && (
<span className={`badge ${updateInfo.hasUpdate ? 'badge-warning' : 'badge-success'}`}>
{updateInfo.message}
</span>
)}
{/* Error Message */}
{error && (
<span className="text-red-400 text-sm">{error}</span>
)}
{/* Disabled Hint */}
{isServerRunning && (
<span className="text-neutral-500 text-sm">Server muss offline sein</span>
)}
{/* Confirm Modal */}
{showConfirm && (
<ConfirmModal
title="Server aktualisieren?"
message={`Möchtest du ${server.name} aktualisieren? Dies wird auch eine Discord-Benachrichtigung senden.`}
confirmText="Aktualisieren"
variant="primary"
onConfirm={handleUpdate}
onCancel={() => setShowConfirm(false)}
/>
)}
</div>
)
}

View File

@@ -10,6 +10,7 @@ import ZomboidConfigEditor from '../components/ZomboidConfigEditor'
import TerrariaConfigEditor from '../components/TerrariaConfigEditor'
import OpenTTDConfigEditor from '../components/OpenTTDConfigEditor'
import HytaleConfigEditor from '../components/HytaleConfigEditor'
import ServerUpdateButton from '../components/ServerUpdateButton'
const getServerLogo = (serverName) => {
const name = serverName.toLowerCase()
@@ -493,6 +494,21 @@ const formatUptime = (seconds) => {
</div>
</div>
)}
{/* Server Update */}
{isModerator && server.type === 'factorio' && (
<div className="card p-4">
<h3 className="text-sm font-medium text-neutral-300 mb-3">Server Update</h3>
<p className="text-neutral-500 text-sm mb-4">
Prüfe auf neue Versionen und aktualisiere den Server
</p>
<ServerUpdateButton
server={server}
token={token}
onUpdateComplete={() => fetchServer()}
/>
</div>
)}
</div>
)}