import { NodeSSH } from "node-ssh"; import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const sshConnections = new Map(); const failedHosts = new Map(); // Cache failed connections const FAILED_HOST_TTL = 60000; // 60 seconds before retry const SSH_TIMEOUT = 5000; function loadConfig() { return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8")); } // Check if host is marked as failed (non-blocking) export function isHostFailed(host, username = "root") { const key = username + "@" + host; const failedAt = failedHosts.get(key); return failedAt && Date.now() - failedAt < FAILED_HOST_TTL; } // Mark host as failed export function markHostFailed(host, username = "root") { const key = username + "@" + host; failedHosts.set(key, Date.now()); } // Clear failed status export function clearHostFailed(host, username = "root") { const key = username + "@" + host; failedHosts.delete(key); } async function getConnection(host, username = "root") { const key = username + "@" + host; // Check if host recently failed - throw immediately if (isHostFailed(host, username)) { throw new Error("Host recently unreachable"); } if (sshConnections.has(key)) { const conn = sshConnections.get(key); if (conn.isConnected()) return conn; sshConnections.delete(key); } const ssh = new NodeSSH(); try { await ssh.connect({ host, username, privateKeyPath: "/root/.ssh/id_ed25519", readyTimeout: SSH_TIMEOUT }); clearHostFailed(host, username); sshConnections.set(key, ssh); return ssh; } catch (err) { markHostFailed(host, username); throw err; } } // Returns: "online", "starting", "stopping", "offline" export async function getServerStatus(server) { try { const ssh = await getConnection(server.host, server.sshUser); if (server.runtime === 'docker') { const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`); const status = result.stdout.trim(); if (status === 'running') return 'online'; if (status === 'restarting' || status === 'created') return 'starting'; if (status === 'removing' || status === 'paused') return 'stopping'; return 'offline'; } else if (server.runtime === 'systemd') { const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`); const status = result.stdout.trim(); if (status === 'active') return 'online'; if (status === 'activating' || status === 'reloading') return 'starting'; if (status === 'deactivating') return 'stopping'; return 'offline'; } else { const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); if (result.code === 0) { const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`); const uptime = parseInt(uptimeResult.stdout.trim()) || 999; if (uptime < 60) return 'starting'; return 'online'; } return 'offline'; } } catch (err) { console.error(`Failed to get status for ${server.id}:`, err.message); return 'offline'; } } export async function startServer(server, options = {}) { const ssh = await getConnection(server.host, server.sshUser); if (server.runtime === 'docker') { // Factorio with specific save if (server.type === 'factorio' && options.save) { const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save; // Stop and remove existing container await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`); await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`); // Start with specific save await ssh.execCommand(` docker run -d \ --name ${server.containerName} \ -p 34197:34197/udp \ -p 27015:27015/tcp \ -v /srv/docker/factorio/data:/factorio \ -e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \ -e TZ=Europe/Berlin \ -e PUID=845 \ -e PGID=845 \ --restart=unless-stopped \ factoriotools/factorio `); } else { await ssh.execCommand(`docker start ${server.containerName}`); } } else if (server.runtime === 'systemd') { await ssh.execCommand(`systemctl start ${server.serviceName}`); } else { await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`); await new Promise(resolve => setTimeout(resolve, 1000)); await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`); } } export async function stopServer(server) { const ssh = await getConnection(server.host, server.sshUser); if (server.runtime === 'docker') { await ssh.execCommand(`docker stop ${server.containerName}`); } else if (server.runtime === 'systemd') { await ssh.execCommand(`systemctl stop ${server.serviceName}`); } else { // Different stop commands per server type const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop'; await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`); for (let i = 0; i < 30; i++) { await new Promise(resolve => setTimeout(resolve, 2000)); const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); if (check.code !== 0) { console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`); return; } } console.log(`Force killing ${server.id} after timeout`); await ssh.execCommand(`screen -S ${server.screenName} -X quit`); await new Promise(resolve => setTimeout(resolve, 2000)); } } export async function restartServer(server) { await stopServer(server); await new Promise(resolve => setTimeout(resolve, 2000)); await startServer(server); } export async function getConsoleLog(server, lines = 50) { const ssh = await getConnection(server.host, server.sshUser); if (server.runtime === 'docker') { const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`); return result.stdout || result.stderr; } else if (server.runtime === 'systemd') { const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`); return result.stdout || result.stderr; } else if (server.logFile) { const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`); return result.stdout; } else { const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`); return result.stdout; } } export async function getProcessUptime(server) { try { const ssh = await getConnection(server.host, server.sshUser); if (server.runtime === "docker") { const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`); if (result.stdout.trim()) { const startTime = new Date(result.stdout.trim()); if (!isNaN(startTime.getTime())) { return Math.floor((Date.now() - startTime.getTime()) / 1000); } } } else if (server.runtime === "systemd") { const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`); const startEpoch = parseInt(result.stdout.trim()); if (!isNaN(startEpoch)) { return Math.floor(Date.now() / 1000) - startEpoch; } } else { const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`); const uptime = parseInt(result.stdout.trim()); if (!isNaN(uptime)) return uptime; } return 0; } catch (err) { console.error(`Failed to get uptime for ${server.id}:`, err.message); return 0; } } // ============ FACTORIO-SPECIFIC FUNCTIONS ============ export async function listFactorioSaves(server) { const ssh = await getConnection(server.host, server.sshUser); const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null'); if (result.code !== 0 || !result.stdout.trim()) { return []; } const saves = []; const lines = result.stdout.trim().split('\n'); for (const line of lines) { const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); if (match) { const filename = match[7].split('/').pop(); // Skip autosaves if (filename.startsWith('_autosave')) continue; const size = match[5]; const modified = match[6]; saves.push({ name: filename.replace('.zip', ''), filename, size, modified }); } } return saves; } export async function deleteFactorioSave(server, saveName) { const ssh = await getConnection(server.host, server.sshUser); const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`; // Security check - prevent path traversal if (filename.includes('/') || filename.includes('..')) { throw new Error('Invalid save name'); } const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`); if (result.code !== 0) { throw new Error(result.stderr || 'Failed to delete save'); } } export async function createFactorioWorld(server, saveName, settings) { const ssh = await getConnection(server.host, server.sshUser); // Security check if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) { throw new Error("Invalid save name - no spaces or special characters allowed"); } // Write map-gen-settings.json const settingsJson = JSON.stringify(settings, null, 2); await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF' ${settingsJson} SETTINGSEOF`); // Create new world using --create flag (correct method) console.log(`Creating new Factorio world: ${saveName}`); const createResult = await ssh.execCommand(` docker run --rm \ -v /srv/docker/factorio/data:/factorio \ factoriotools/factorio \ /opt/factorio/bin/x64/factorio \ --create /factorio/saves/${saveName}.zip \ --map-gen-settings /factorio/config/map-gen-settings.json `, { execOptions: { timeout: 120000 } }); console.log("World creation output:", createResult.stdout, createResult.stderr); // Verify save was created const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`); if (checkResult.code !== 0) { throw new Error("World creation failed - save file not found. Output: " + createResult.stderr); } return true; } export async function getFactorioCurrentSave(server) { const ssh = await getConnection(server.host, server.sshUser); // Check if container has SAVE_NAME env set const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`); if (envResult.stdout.trim()) { const saveName = envResult.stdout.trim().replace("SAVE_NAME=", ""); return { save: saveName, source: "configured" }; } // Otherwise find newest save file const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1"); if (result.stdout.trim()) { const filename = result.stdout.trim().split("/").pop().replace(".zip", ""); return { save: filename, source: "newest" }; } return { save: null, source: null }; } // ============ ZOMBOID CONFIG FUNCTIONS ============ const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server"; const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"]; export async function listZomboidConfigs(server) { const ssh = await getConnection(server.host, server.sshUser); const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`; const result = await ssh.execCommand(cmd); if (result.code !== 0 || !result.stdout.trim()) { return []; } const files = []; const lines = result.stdout.trim().split("\n"); for (const line of lines) { const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); if (match) { const fullPath = match[7]; const filename = fullPath.split("/").pop(); if (!ALLOWED_CONFIG_FILES.includes(filename)) continue; files.push({ filename, size: parseInt(match[5]), modified: match[6] }); } } return files; } export async function readZomboidConfig(server, filename) { if (!ALLOWED_CONFIG_FILES.includes(filename)) { throw new Error("File not allowed"); } if (filename.includes("/") || filename.includes("..")) { throw new Error("Invalid filename"); } const ssh = await getConnection(server.host, server.sshUser); const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`); if (result.code !== 0) { throw new Error(result.stderr || "Failed to read config file"); } return result.stdout; } export async function writeZomboidConfig(server, filename, content) { if (!ALLOWED_CONFIG_FILES.includes(filename)) { throw new Error("File not allowed"); } if (filename.includes("/") || filename.includes("..")) { throw new Error("Invalid filename"); } const ssh = await getConnection(server.host, server.sshUser); // Create backup const backupName = `${filename}.backup.${Date.now()}`; await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`); // Write file using sftp const sftp = await ssh.requestSFTP(); const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`; await new Promise((resolve, reject) => { sftp.writeFile(filePath, content, (err) => { if (err) reject(err); else resolve(); }); }); // Clean up old backups (keep last 5) await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`); return true; } // ============ PALWORLD CONFIG ============ const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer"; const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"]; export async function listPalworldConfigs(server) { const ssh = await getConnection(server.host); const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`; const result = await ssh.execCommand(cmd); if (result.code !== 0 || !result.stdout.trim()) { return []; } const files = []; const lines = result.stdout.trim().split("\n"); for (const line of lines) { const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/); if (match) { const fullPath = match[7]; const filename = fullPath.split("/").pop(); if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue; files.push({ filename, size: parseInt(match[5]), modified: match[6] }); } } return files; } export async function readPalworldConfig(server, filename) { if (!PALWORLD_ALLOWED_FILES.includes(filename)) { throw new Error("File not allowed"); } if (filename.includes("/") || filename.includes("..")) { throw new Error("Invalid filename"); } const ssh = await getConnection(server.host); const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`); if (result.code !== 0) { throw new Error(result.stderr || "Failed to read config file"); } return result.stdout; } export async function writePalworldConfig(server, filename, content) { if (!PALWORLD_ALLOWED_FILES.includes(filename)) { throw new Error("File not allowed"); } if (filename.includes("/") || filename.includes("..")) { throw new Error("Invalid filename"); } const ssh = await getConnection(server.host); // Create backup const backupName = `${filename}.backup.${Date.now()}`; await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`); // Write file using sftp const sftp = await ssh.requestSFTP(); const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`; await new Promise((resolve, reject) => { sftp.writeFile(filePath, content, (err) => { if (err) reject(err); else resolve(); }); }); // Clean up old backups (keep last 5) await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`); return true; }