Files
GSM/gsm-backend/services/ssh.js
Alexander Zielonka 0b5e573d45
All checks were successful
Deploy GSM / deploy (push) Successful in 24s
Fix Terraria log parsing for player leave detection
Changed detection pattern from "hat das Spiel verlassen" to "ist weg"
to match actual Terraria server log format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:59:36 +01:00

771 lines
28 KiB
JavaScript

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 if (server.runtime === 'pm2') {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
const result = await ssh.execCommand(nvmPrefix + "pm2 jlist");
try {
const processes = JSON.parse(result.stdout);
const proc = processes.find(p => p.name === server.serviceName);
if (!proc) return "offline";
if (proc.pm2_env.status === "online") return "online";
if (proc.pm2_env.status === "launching") return "starting";
if (proc.pm2_env.status === "stopping") return "stopping";
return "offline";
} catch { return "offline"; }
} else if (server.runtime === 'tmux') {
const result = await ssh.execCommand(`tmux has-session -t ${server.tmuxName} 2>/dev/null && echo running || echo stopped`);
if (result.stdout.trim() === 'running') {
const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(tmux list-panes -t ${server.tmuxName} -F '#{pane_pid}' 2>/dev/null | head -1) 2>/dev/null | head -1`);
const uptime = parseInt(uptimeResult.stdout.trim()) || 999;
if (uptime < 60) return 'starting';
return 'online';
}
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') {
const sudoCmd = server.external ? "sudo " : ""; await ssh.execCommand(`${sudoCmd}systemctl start ${server.serviceName}`);
} else if (server.runtime === 'pm2') {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
await ssh.execCommand(nvmPrefix + "pm2 start " + server.serviceName);
} else if (server.runtime === 'tmux') {
await ssh.execCommand(`tmux kill-session -t ${server.tmuxName} 2>/dev/null || true`);
await new Promise(resolve => setTimeout(resolve, 1000));
await ssh.execCommand(`cd ${server.workDir} && tmux new-session -d -s ${server.tmuxName} '${server.startCmd}'`);
} 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') {
const sudoCmd2 = server.external ? "sudo " : ""; await ssh.execCommand(`${sudoCmd2}systemctl stop ${server.serviceName}`);
} else if (server.runtime === 'pm2') {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
await ssh.execCommand(nvmPrefix + "pm2 stop " + server.serviceName);
} else if (server.runtime === 'tmux') {
// Send stop command via tmux
const stopCmd = server.stopCmd || 'stop';
await ssh.execCommand(`tmux send-keys -t ${server.tmuxName} '${stopCmd}' Enter`);
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 2000));
const check = await ssh.execCommand(`tmux has-session -t ${server.tmuxName} 2>/dev/null && echo running || echo stopped`);
if (check.stdout.trim() === 'stopped') {
console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`);
return;
}
}
console.log(`Force killing ${server.id} after timeout`);
await ssh.execCommand(`tmux kill-session -t ${server.tmuxName} 2>/dev/null || true`);
await new Promise(resolve => setTimeout(resolve, 2000));
return;
} 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') {
let cmd = `tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`;
// Filter out REST API spam for Palworld
if (server.type === 'palworld') {
cmd = `journalctl -u ${server.serviceName} -n ${lines * 2} --no-pager | grep -v "REST accessed endpoint" | tail -n ${lines}`;
}
const result = await ssh.execCommand(cmd);
return result.stdout || result.stderr;
} else if (server.runtime === 'pm2') {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
const result = await ssh.execCommand(nvmPrefix + "pm2 logs " + server.serviceName + " --lines " + lines + " --nostream");
return result.stdout || result.stderr;
} else if (server.runtime === 'tmux') {
// For tmux, use logCmd, logFile, or capture pane content
if (server.logCmd) {
const result = await ssh.execCommand(`${server.logCmd} ${lines} 2>/dev/null || echo No log file found`);
return result.stdout;
} 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 {
// Capture tmux pane content (last N lines)
const result = await ssh.execCommand(`tmux capture-pane -t ${server.tmuxName} -p -S -${lines} 2>/dev/null || echo Session not found`);
return result.stdout || 'No logs available';
}
} 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 if (server.runtime === "pm2") {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
const result = await ssh.execCommand(nvmPrefix + "pm2 jlist");
try {
const processes = JSON.parse(result.stdout);
const proc = processes.find(p => p.name === server.serviceName);
if (proc && proc.pm2_env.pm_uptime) {
return Math.floor((Date.now() - proc.pm2_env.pm_uptime) / 1000);
}
} catch {}
return 0;
} else if (server.runtime === "tmux") {
const result = await ssh.execCommand(`ps -o etimes= -p $(tmux list-panes -t ${server.tmuxName} -F '#{pane_pid}' 2>/dev/null | head -1) 2>/dev/null | head -1`);
const uptime = parseInt(result.stdout.trim());
if (!isNaN(uptime)) return uptime;
return 0;
} 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;
}
// ============ TERRARIA CONFIG ============
const TERRARIA_CONFIG_PATH = "/home/terraria/tModLoader/serverconfig.txt";
export async function readTerrariaConfig(server) {
const ssh = await getConnection(server.host, server.sshUser);
const result = await ssh.execCommand(`cat ${TERRARIA_CONFIG_PATH}`);
if (result.code !== 0) {
throw new Error(result.stderr || "Failed to read config file");
}
return result.stdout;
}
export async function writeTerrariaConfig(server, content) {
const ssh = await getConnection(server.host, server.sshUser);
// Create backup
const backupName = `serverconfig.txt.backup.${Date.now()}`;
await ssh.execCommand(`cp ${TERRARIA_CONFIG_PATH} /home/terraria/tModLoader/${backupName} 2>/dev/null || true`);
// Write file using sftp
const sftp = await ssh.requestSFTP();
await new Promise((resolve, reject) => {
sftp.writeFile(TERRARIA_CONFIG_PATH, content, (err) => {
if (err) reject(err);
else resolve();
});
});
// Clean up old backups (keep last 5)
await ssh.execCommand(`ls -t /home/terraria/tModLoader/serverconfig.txt.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
return true;
}
// OpenTTD Config
const OPENTTD_CONFIG_PATH = "/opt/openttd/.openttd/openttd.cfg";
export async function readOpenTTDConfig(server) {
const ssh = await getConnection(server.host, server.sshUser);
const result = await ssh.execCommand(`cat ${OPENTTD_CONFIG_PATH}`);
if (result.code !== 0) {
throw new Error(result.stderr || "Failed to read config file");
}
return result.stdout;
}
export async function writeOpenTTDConfig(server, content) {
const ssh = await getConnection(server.host, server.sshUser);
const backupName = `openttd.cfg.backup.${Date.now()}`;
await ssh.execCommand(`cp ${OPENTTD_CONFIG_PATH} /opt/openttd/.openttd/${backupName} 2>/dev/null || true`);
const sftp = await ssh.requestSFTP();
await new Promise((resolve, reject) => {
sftp.writeFile(OPENTTD_CONFIG_PATH, content, (err) => {
if (err) reject(err);
else resolve();
});
});
await ssh.execCommand(`ls -t /opt/openttd/.openttd/openttd.cfg.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
return true;
}
// ============ TERRARIA FUNCTIONS ============
// Get Terraria players by parsing PM2 logs
export async function getTerrariaPlayers(server) {
try {
const ssh = await getConnection(server.host, server.sshUser);
// Get last 500 lines of PM2 logs
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
const result = await ssh.execCommand(nvmPrefix + `pm2 logs ${server.serviceName} --lines 500 --nostream 2>/dev/null | grep -E "ist beigetreten|ist weg|has joined|has left" | tail -100`);
const players = new Map(); // PlayerName -> true
const lines = result.stdout.split('\n').filter(l => l.trim());
for (const line of lines) {
// German: "Lokführer ist beigetreten." / "Lokführer ist weg."
// English: "PlayerName has joined." / "PlayerName has left."
// Join patterns
const joinMatchDE = line.match(/^\d+\|[^\|]+\s*\|\s*(.+?)\s+ist beigetreten\.?$/i);
const joinMatchEN = line.match(/^\d+\|[^\|]+\s*\|\s*(.+?)\s+has joined\.?$/i);
if (joinMatchDE) {
players.set(joinMatchDE[1].trim(), true);
continue;
}
if (joinMatchEN) {
players.set(joinMatchEN[1].trim(), true);
continue;
}
// Leave patterns
const leaveMatchDE = line.match(/^\d+\|[^\|]+\s*\|\s*(.+?)\s+ist weg\.?$/i);
const leaveMatchEN = line.match(/^\d+\|[^\|]+\s*\|\s*(.+?)\s+has left\.?$/i);
if (leaveMatchDE) {
players.delete(leaveMatchDE[1].trim());
continue;
}
if (leaveMatchEN) {
players.delete(leaveMatchEN[1].trim());
}
}
const playerList = Array.from(players.keys());
return { online: playerList.length, players: playerList };
} catch (err) {
console.error(`[Terraria] Error getting players:`, err.message);
return { online: 0, players: [] };
}
}
// ============ HYTALE FUNCTIONS ============
const HYTALE_CONFIG_PATH = "/opt/hytale/Server/config.json";
const HYTALE_LOGS_PATH = "/opt/hytale/Server/logs";
// Get Hytale players by parsing server logs
export async function getHytalePlayers(server) {
try {
const ssh = await getConnection(server.host, server.sshUser);
// Find the most recent log file
const logFileResult = await ssh.execCommand(`ls -t ${HYTALE_LOGS_PATH}/*.log 2>/dev/null | head -1`);
if (!logFileResult.stdout.trim()) {
return { online: 0, players: [] };
}
const logFile = logFileResult.stdout.trim();
// Parse log for player joins and disconnects
// Join: [World|default] Player 'Alex47' joined world 'default' at location ... (UUID)
// Leave: [PlayerSystems] Removing player 'Alex47 (Alex47)' from world 'default' (UUID)
const result = await ssh.execCommand(`grep -E "\\[World\\|.*\\] Player .* joined world|\\[PlayerSystems\\] Removing player" ${logFile} 2>/dev/null | tail -200`);
const players = new Map(); // UUID -> PlayerName
const lines = result.stdout.split('\n').filter(l => l.trim());
for (const line of lines) {
// Check for player join: [World|default] Player 'Name' joined world ... (uuid)
const joinMatch = line.match(/\[World\|[^\]]+\] Player '([^']+)' joined world .* \(([a-f0-9-]+)\)/i);
if (joinMatch) {
const playerName = joinMatch[1];
const uuid = joinMatch[2];
players.set(uuid, playerName);
continue;
}
// Check for player leave: [PlayerSystems] Removing player 'Name (Name)' from world ... (uuid)
const leaveMatch = line.match(/\[PlayerSystems\] Removing player '([^']+) \([^)]+\)' from world .* \(([a-f0-9-]+)\)/i);
if (leaveMatch) {
const uuid = leaveMatch[2];
players.delete(uuid);
}
}
const playerList = Array.from(players.values());
return { online: playerList.length, players: playerList };
} catch (err) {
console.error(`[Hytale] Error getting players:`, err.message);
return { online: 0, players: [] };
}
}
export async function readHytaleConfig(server) {
const ssh = await getConnection(server.host, server.sshUser);
const result = await ssh.execCommand(`cat ${HYTALE_CONFIG_PATH}`);
if (result.code !== 0) {
throw new Error(result.stderr || "Failed to read config file");
}
return result.stdout;
}
export async function writeHytaleConfig(server, content) {
const ssh = await getConnection(server.host, server.sshUser);
const backupName = `config.json.backup.${Date.now()}`;
await ssh.execCommand(`cp ${HYTALE_CONFIG_PATH} /opt/hytale/Server/${backupName} 2>/dev/null || true`);
const sftp = await ssh.requestSFTP();
await new Promise((resolve, reject) => {
sftp.writeFile(HYTALE_CONFIG_PATH, content, (err) => {
if (err) reject(err);
else resolve();
});
});
await ssh.execCommand(`ls -t /opt/hytale/Server/config.json.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
return true;
}