Files
GSM/gsm-backend/services/ssh.js
Alexander Zielonka a88437f95f
All checks were successful
Deploy GSM / deploy (push) Successful in 25s
Add world management for Space Engineers (list/switch/create/clone/rename/delete)
Mirrors Factorio's world feature for the SE server. The SE docker image always
loads the flat config/World dir and its entrypoint rewrites <LoadWorld> on every
start, so repointing the cfg is useless. Instead inactive worlds live in a
sibling config/Worlds library (not mounted) and switching is an instant mv-swap;
the active world name is tracked in .gsm-worldname and files are chowned to
1000:1000. New worlds are created from the image's built-in CustomWorlds
templates or by cloning the active world. Switch/clone require a stopped server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:12:45 +02:00

1110 lines
44 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;
const STATUS_TIMEOUT = 8000; // max time for a status/uptime check before treating the host as unreachable
// Race a promise against a timeout. Rejects with `label` if it doesn't settle in time.
// Used so a hung SSH command (e.g. host powered off mid-session, socket not yet dead)
// can't stall the whole status aggregation.
function withTimeout(promise, ms, label = "operation-timed-out") {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
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,
// Detect dead peers fast: if a host is powered off, the TCP socket would
// otherwise stay "connected" for ~2h. Keepalive closes it in ~15s so the
// cached connection is invalidated and the next check fails fast -> offline.
keepaliveInterval: 5000,
keepaliveCountMax: 3
});
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) {
const connKey = (server.sshUser || "root") + "@" + server.host;
let connected = false;
const check = (async () => {
const ssh = await getConnection(server.host, server.sshUser);
connected = true;
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') return 'starting';
if (status === 'removing' || status === 'paused') return 'stopping';
return 'offline'; // includes 'created', 'exited', 'dead'
} 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';
}
})();
check.catch(() => {}); // prevent unhandled rejection if the timeout wins the race
try {
return await withTimeout(check, STATUS_TIMEOUT, "status-timeout");
} catch (err) {
console.error(`Failed to get status for ${server.id}:`, err.message);
if (err.message === "status-timeout") {
// Connection hung (host likely powered off mid-session) -> drop it and trip the breaker
sshConnections.delete(connKey);
markHostFailed(server.host, server.sshUser);
return 'unreachable';
}
// Couldn't reach the host at all -> "unreachable" so the dashboard hides it
// consistently, instead of flapping to "offline" every time the failed-host
// breaker expires and a fresh connect is attempted. Reachable but the runtime
// command failed -> "offline".
return connected ? 'offline' : 'unreachable';
}
}
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) {
const connKey = (server.sshUser || "root") + "@" + server.host;
const check = (async () => {
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;
})();
check.catch(() => {}); // prevent unhandled rejection if the timeout wins the race
try {
return await withTimeout(check, STATUS_TIMEOUT, "uptime-timeout");
} catch (err) {
console.error(`Failed to get uptime for ${server.id}:`, err.message);
if (err.message === "uptime-timeout") {
sshConnections.delete(connKey);
markHostFailed(server.host, server.sshUser);
}
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: [] };
}
}
// ============ SPACE ENGINEERS FUNCTIONS ============
// Get Space Engineers players by parsing docker logs
export async function getSpaceEngineersPlayers(server) {
try {
const ssh = await getConnection(server.host, server.sshUser);
// SE (via SpaceEngineersDedicated.exe under wine) emits:
// Join: "OnConnectedClient <Name> attempt"
// Leave (resolved): "User left <Name>"
// Leave (unresolved): "User left [<steamid-prefix>...]" (failed/kicked attempt)
const result = await ssh.execCommand(
`docker logs --tail 3000 ${server.containerName} 2>&1 | ` +
`grep -E "OnConnectedClient .* attempt|User left " | tail -300`
);
const players = new Map();
const lines = result.stdout.split('\n').filter(l => l.trim());
for (const line of lines) {
let m;
if ((m = line.match(/OnConnectedClient\s+(\S+)\s+attempt/))) {
players.set(m[1], true);
continue;
}
if ((m = line.match(/User left\s+(.+?)\s*$/))) {
const token = m[1].trim();
if (token.startsWith('[')) {
// unresolved leave (failed connection) — drop oldest pending entry
const firstKey = players.keys().next().value;
if (firstKey !== undefined) players.delete(firstKey);
} else {
players.delete(token);
}
}
}
const playerList = Array.from(players.keys());
return { online: playerList.length, players: playerList };
} catch (err) {
console.error(`[SpaceEngineers] Error getting players:`, err.message);
return { online: 0, players: [] };
}
}
// ============ SPACE ENGINEERS WORLD MANAGEMENT ============
//
// Unlike Factorio (which keeps many named saves in one folder and picks one via
// an env var on `docker run`), the SE image always loads exactly ONE flat world
// directory: host `<workDir>/appdata/space-engineers/config/World` -> container
// `/appdata/space-engineers/World`. The image entrypoint even rewrites
// <LoadWorld> back to that fixed path on every start, so repointing the cfg is
// pointless and the world MUST be flat (Sandbox.sbc directly inside) or the
// container exits (code 130).
//
// We therefore keep a library of inactive worlds in a sibling folder `Worlds`
// (plural, NOT mounted into the container) and "switch" by moving directories.
// `Worlds` lives on the same filesystem as `World`, so the move is an instant
// rename even for multi-GB worlds. The active world's display name is tracked in
// a marker file `.gsm-worldname` inside `World`. All files are owned by UID/GID
// 1000 (the in-container wine user), so we chown after every mutation.
function sePaths(server) {
const base = `${server.workDir}/appdata/space-engineers`;
return {
base,
config: `${base}/config`,
active: `${base}/config/World`,
library: `${base}/config/Worlds`,
templates: `${base}/bins/SpaceEngineersDedicated/Content/CustomWorlds`,
};
}
// World/template names map to directory names. Allow letters, numbers, spaces,
// underscore and hyphen only — this both keeps the file system tidy and makes it
// safe to interpolate the (double-quoted) name into shell commands.
function validateSEWorldName(name) {
if (!name || typeof name !== 'string') throw new Error('Weltname fehlt');
const trimmed = name.trim();
if (trimmed.includes('..')) throw new Error('Ungültiger Weltname');
if (!/^[A-Za-z0-9 _-]{1,48}$/.test(trimmed)) {
throw new Error('Ungültiger Weltname (erlaubt: Buchstaben, Zahlen, Leerzeichen, _ und -, max. 48 Zeichen)');
}
return trimmed;
}
function sanitizeSEName(raw) {
const s = (raw || '').trim().replace(/[^A-Za-z0-9 _-]/g, '').slice(0, 48).trim();
return s || 'World';
}
// Resolve the active world's display name. Prefer the marker file; otherwise
// derive it from the cfg <WorldName> (falling back to <SessionName>) and persist
// a marker so subsequent switches have a stable name to archive under.
async function ensureSEActiveMarker(server, ssh) {
const p = sePaths(server);
const marker = await ssh.execCommand(`cat "${p.active}/.gsm-worldname" 2>/dev/null`);
if (marker.stdout.trim()) return marker.stdout.trim();
let derived = '';
const cfg = await ssh.execCommand(
`grep -m1 -oE "<WorldName>[^<]*" "${p.config}/SpaceEngineers-Dedicated.cfg" 2>/dev/null | sed -E "s/<WorldName>//"`
);
derived = cfg.stdout.trim();
if (!derived) {
const sess = await ssh.execCommand(
`grep -m1 -oE "<SessionName>[^<]*" "${p.active}/Sandbox_config.sbc" 2>/dev/null | sed -E "s/<SessionName>//"`
);
derived = sess.stdout.trim();
}
derived = sanitizeSEName(derived);
// best effort: persist marker so the active name is stable from now on
await ssh.execCommand(
`echo "${derived}" > "${p.active}/.gsm-worldname" && chown 1000:1000 "${p.active}/.gsm-worldname" 2>/dev/null || true`
);
return derived;
}
// List the active world + every world in the library, with size and mtime.
export async function listSpaceEngineersWorlds(server) {
const ssh = await getConnection(server.host, server.sshUser);
const p = sePaths(server);
await ssh.execCommand(`mkdir -p "${p.library}"`);
await ensureSEActiveMarker(server, ssh);
// One pass emits "active|name|hasSandbox|size|mtime" per world. Names are
// restricted to a charset without "|" so the delimiter is unambiguous.
const script =
`ACTIVE="${p.active}"; LIB="${p.library}"; ` +
`emit() { d="$1"; act="$2"; nm="$3"; has=0; [ -f "$d/Sandbox.sbc" ] && has=1; ` +
`size=$(du -sh "$d" 2>/dev/null | cut -f1); ` +
`mt=$(stat -c %Y "$d/Sandbox.sbc" 2>/dev/null || echo 0); ` +
`echo "$act|$nm|$has|$size|$mt"; }; ` +
`an=$(cat "$ACTIVE/.gsm-worldname" 2>/dev/null); [ -z "$an" ] && an="World"; ` +
`emit "$ACTIVE" 1 "$an"; ` +
`for d in "$LIB"/*/ ; do [ -d "$d" ] || continue; emit "${'${d%/}'}" 0 "$(basename "$d")"; done`;
const r = await ssh.execCommand(script);
const worlds = [];
for (const line of r.stdout.split('\n').map(l => l.trim()).filter(Boolean)) {
const parts = line.split('|');
if (parts.length < 5) continue;
const [act, name, has, size, mt] = parts;
worlds.push({
name,
active: act === '1',
valid: has === '1',
size: size || '-',
modified: mt && mt !== '0' ? Number(mt) * 1000 : null,
});
}
const active = worlds.find(w => w.active)?.name || null;
return { active, worlds };
}
// Built-in premade worlds shipped with the dedicated server, usable as templates
// for a fresh world. Some templates store the world under a PC/ subfolder.
export async function listSpaceEngineersTemplates(server) {
const ssh = await getConnection(server.host, server.sshUser);
const p = sePaths(server);
const script =
`for d in "${p.templates}"/*/ ; do [ -d "$d" ] || continue; nm="$(basename "$d")"; ` +
`if [ -f "$d/Sandbox.sbc" ] || [ -f "$d/PC/Sandbox.sbc" ]; then echo "$nm"; fi; done`;
const r = await ssh.execCommand(script);
return r.stdout.split('\n').map(s => s.trim()).filter(Boolean);
}
// Swap the active world. The server MUST be stopped (checked by the route).
export async function switchSpaceEngineersWorld(server, targetName) {
const name = validateSEWorldName(targetName);
const ssh = await getConnection(server.host, server.sshUser);
const p = sePaths(server);
const current = await ensureSEActiveMarker(server, ssh);
if (name === current) return { message: 'Welt ist bereits aktiv', active: name };
const tgt = await ssh.execCommand(`test -f "${p.library}/${name}/Sandbox.sbc" && echo OK`);
if (!tgt.stdout.includes('OK')) {
throw new Error(`Welt "${name}" nicht in der Bibliothek gefunden (oder keine Sandbox.sbc)`);
}
const coll = await ssh.execCommand(`test -e "${p.library}/${current}" && echo EXISTS`);
if (coll.stdout.includes('EXISTS')) {
throw new Error(`Kann aktive Welt nicht archivieren: "Worlds/${current}" existiert bereits`);
}
const cmd =
`set -e; mkdir -p "${p.library}"; ` +
`mv "${p.active}" "${p.library}/${current}"; ` +
`mv "${p.library}/${name}" "${p.active}"; ` +
`echo "${name}" > "${p.active}/.gsm-worldname"; ` +
`chown -R 1000:1000 "${p.active}" "${p.library}/${current}";`;
const r = await ssh.execCommand(cmd);
if (r.code !== 0) throw new Error(r.stderr || 'Weltwechsel fehlgeschlagen');
const ver = await ssh.execCommand(`test -f "${p.active}/Sandbox.sbc" && echo OK`);
if (!ver.stdout.includes('OK')) throw new Error('Weltwechsel-Verifikation fehlgeschlagen (Sandbox.sbc fehlt)');
return { message: 'Welt gewechselt', active: name };
}
// Create a new library world from a built-in template.
export async function createSpaceEngineersWorldFromTemplate(server, newName, templateName) {
const name = validateSEWorldName(newName);
const tmpl = validateSEWorldName(templateName);
const ssh = await getConnection(server.host, server.sshUser);
const p = sePaths(server);
const active = await ensureSEActiveMarker(server, ssh);
if (name === active) throw new Error('Eine Welt mit diesem Namen ist gerade aktiv');
const exists = await ssh.execCommand(`test -e "${p.library}/${name}" && echo EXISTS`);
if (exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" existiert bereits`);
const src = await ssh.execCommand(
`if [ -f "${p.templates}/${tmpl}/Sandbox.sbc" ]; then echo "${p.templates}/${tmpl}"; ` +
`elif [ -f "${p.templates}/${tmpl}/PC/Sandbox.sbc" ]; then echo "${p.templates}/${tmpl}/PC"; fi`
);
const srcDir = src.stdout.trim();
if (!srcDir) throw new Error(`Vorlage "${tmpl}" nicht gefunden oder ohne ladbare Welt`);
const cmd =
`set -e; mkdir -p "${p.library}/${name}"; ` +
`cp -a "${srcDir}/." "${p.library}/${name}/"; ` +
`echo "${name}" > "${p.library}/${name}/.gsm-worldname"; ` +
`chown -R 1000:1000 "${p.library}/${name}";`;
const r = await ssh.execCommand(cmd, { execOptions: { timeout: 120000 } });
if (r.code !== 0) throw new Error(r.stderr || 'Welterstellung fehlgeschlagen');
return { message: 'Welt erstellt', name };
}
// Duplicate the currently active world into the library. Server MUST be stopped
// (checked by the route) so the copy is not torn by an autosave.
export async function cloneSpaceEngineersWorld(server, newName) {
const name = validateSEWorldName(newName);
const ssh = await getConnection(server.host, server.sshUser);
const p = sePaths(server);
const active = await ensureSEActiveMarker(server, ssh);
if (name === active) throw new Error('Klon-Name muss sich von der aktiven Welt unterscheiden');
const exists = await ssh.execCommand(`test -e "${p.library}/${name}" && echo EXISTS`);
if (exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" existiert bereits`);
const cmd =
`set -e; mkdir -p "${p.library}/${name}"; ` +
`cp -a "${p.active}/." "${p.library}/${name}/"; ` +
`echo "${name}" > "${p.library}/${name}/.gsm-worldname"; ` +
`chown -R 1000:1000 "${p.library}/${name}";`;
const r = await ssh.execCommand(cmd, { execOptions: { timeout: 300000 } });
if (r.code !== 0) throw new Error(r.stderr || 'Klonen fehlgeschlagen');
return { message: 'Welt geklont', name };
}
export async function deleteSpaceEngineersWorld(server, worldName) {
const name = validateSEWorldName(worldName);
const ssh = await getConnection(server.host, server.sshUser);
const p = sePaths(server);
const active = await ensureSEActiveMarker(server, ssh);
if (name === active) throw new Error('Aktive Welt kann nicht gelöscht werden. Wechsle zuerst zu einer anderen Welt.');
const exists = await ssh.execCommand(`test -d "${p.library}/${name}" && echo EXISTS`);
if (!exists.stdout.includes('EXISTS')) throw new Error(`Welt "${name}" nicht gefunden`);
const r = await ssh.execCommand(`rm -rf "${p.library}/${name}"`);
if (r.code !== 0) throw new Error(r.stderr || 'Löschen fehlgeschlagen');
return { message: 'Welt gelöscht', name };
}
export async function renameSpaceEngineersWorld(server, oldName, newName) {
const oldN = validateSEWorldName(oldName);
const newN = validateSEWorldName(newName);
if (oldN === newN) return { message: 'Keine Änderung', name: newN };
const ssh = await getConnection(server.host, server.sshUser);
const p = sePaths(server);
const active = await ensureSEActiveMarker(server, ssh);
if (newN === active) throw new Error('Eine Welt mit dem neuen Namen ist gerade aktiv');
const collide = await ssh.execCommand(`test -e "${p.library}/${newN}" && echo EXISTS`);
if (collide.stdout.includes('EXISTS')) throw new Error(`Welt "${newN}" existiert bereits`);
if (oldN === active) {
const r = await ssh.execCommand(
`echo "${newN}" > "${p.active}/.gsm-worldname" && chown 1000:1000 "${p.active}/.gsm-worldname"`
);
if (r.code !== 0) throw new Error(r.stderr || 'Umbenennen fehlgeschlagen');
return { message: 'Aktive Welt umbenannt', name: newN };
}
const exists = await ssh.execCommand(`test -d "${p.library}/${oldN}" && echo EXISTS`);
if (!exists.stdout.includes('EXISTS')) throw new Error(`Welt "${oldN}" nicht gefunden`);
const r = await ssh.execCommand(
`set -e; mv "${p.library}/${oldN}" "${p.library}/${newN}"; ` +
`echo "${newN}" > "${p.library}/${newN}/.gsm-worldname"; ` +
`chown 1000:1000 "${p.library}/${newN}/.gsm-worldname";`
);
if (r.code !== 0) throw new Error(r.stderr || 'Umbenennen fehlgeschlagen');
return { message: 'Welt umbenannt', name: newN };
}
// ============ 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;
}