All checks were successful
Deploy GSM / deploy (push) Successful in 25s
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>
1110 lines
44 KiB
JavaScript
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;
|
|
}
|