palworld added

This commit is contained in:
Alexander Zielonka
2026-01-07 12:35:23 +01:00
30 changed files with 5846 additions and 123 deletions

View File

@@ -0,0 +1,159 @@
import { Rcon } from 'rcon-client';
const rconConnections = new Map();
const playerCache = new Map();
const CACHE_TTL = 30000; // 30 seconds
async function getConnection(server) {
const key = `${server.host}:${server.rconPort}`;
if (rconConnections.has(key)) {
const conn = rconConnections.get(key);
if (conn.authenticated) {
return conn;
}
rconConnections.delete(key);
}
const rcon = await Rcon.connect({
host: server.host,
port: server.rconPort,
password: server.rconPassword,
timeout: 5000
});
rcon.on('error', (err) => {
console.error(`RCON error for ${key}:`, err.message);
rconConnections.delete(key);
});
rcon.on('end', () => {
rconConnections.delete(key);
});
// Handle socket errors to prevent crash
if (rcon.socket) {
rcon.socket.on('error', (err) => {
console.error(`RCON socket error for ${key}:`, err.message);
rconConnections.delete(key);
});
}
rconConnections.set(key, rcon);
return rcon;
}
export async function sendRconCommand(server, command) {
if (!server.rconPassword) {
throw new Error('RCON password not configured');
}
const rcon = await getConnection(server);
return await rcon.send(command);
}
export async function getPlayers(server) {
const cacheKey = `${server.id}-count`;
const cached = playerCache.get(cacheKey);
if (cached && Date.now() - cached.time < CACHE_TTL) {
return cached.data;
}
try {
let result = { online: 0, max: null };
if (server.type === 'minecraft') {
const response = await sendRconCommand(server, 'list');
const match = response.match(/There are (\d+) of a max of (\d+) players online/);
if (match) {
result = { online: parseInt(match[1]), max: parseInt(match[2]) };
}
} else if (server.type === 'factorio') {
const response = await sendRconCommand(server, '/players online count');
const match = response.match(/(\d+)/);
if (match) {
result = { online: parseInt(match[1]), max: null };
}
} else if (server.type === 'zomboid') {
const response = await sendRconCommand(server, 'players');
// Format: "Players connected (X):" or "Players connected (X): Player1, Player2"
const match = response.match(/Players connected \((\d+)\)/);
if (match) {
result = { online: parseInt(match[1]), max: null };
}
} else if (server.type === 'vrising') {
const response = await sendRconCommand(server, 'listusers');
// Count lines that contain player info
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
result = { online: lines.length, max: null };
}
playerCache.set(cacheKey, { data: result, time: Date.now() });
return result;
} catch (err) {
console.error(`Failed to get players for ${server.id}:`, err.message);
// Clear cache on error - server might be offline
playerCache.delete(cacheKey);
return { online: 0, max: null };
}
}
export async function getPlayerList(server) {
const cacheKey = `${server.id}-list`;
const cached = playerCache.get(cacheKey);
if (cached && Date.now() - cached.time < CACHE_TTL) {
return cached.data;
}
try {
let players = [];
if (server.type === 'minecraft') {
const response = await sendRconCommand(server, 'list');
// Format: "There are X of a max of Y players online: Player1, Player2, Player3"
const colonIndex = response.indexOf(':');
if (colonIndex !== -1) {
const playerPart = response.substring(colonIndex + 1).trim();
if (playerPart) {
players = playerPart.split(',').map(p => p.trim()).filter(p => p);
}
}
} else if (server.type === 'factorio') {
const response = await sendRconCommand(server, '/players online');
// Format: "Online players (X):\n player1 (online)\n player2 (online)"
const lines = response.split('\n');
for (const line of lines) {
const match = line.match(/^\s*(\S+)\s*\(online\)/);
if (match) {
players.push(match[1]);
}
}
} else if (server.type === 'zomboid') {
const response = await sendRconCommand(server, 'players');
// Format: "Players connected (X): \n-Player1\n-Player2\n-Player3"
const lines = response.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('-')) {
players.push(trimmed.substring(1));
}
}
} else if (server.type === 'vrising') {
const response = await sendRconCommand(server, 'listusers');
// Parse player names from listusers output
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
players = lines.map(l => l.trim()).filter(p => p);
}
const result = { players };
playerCache.set(cacheKey, { data: result, time: Date.now() });
return result;
} catch (err) {
console.error(`Failed to get player list for ${server.id}:`, err.message);
// Clear cache on error - server might be offline
playerCache.delete(cacheKey);
return { players: [] };
}
}

493
gsm-backend/services/ssh.js Normal file
View File

@@ -0,0 +1,493 @@
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;
}