zustand auf server wiederhergestellt
This commit is contained in:
113
gsm-backend/services/autoshutdown.js
Normal file
113
gsm-backend/services/autoshutdown.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getServerStatus, stopServer } from './ssh.js';
|
||||
import { getPlayers } from './rcon.js';
|
||||
import { getAllAutoShutdownSettings, getAutoShutdownSettings } from '../db/init.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Track when each server became empty
|
||||
const emptyPlayersSince = new Map();
|
||||
|
||||
// Check interval in ms (60 seconds)
|
||||
const CHECK_INTERVAL = 60000;
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
|
||||
}
|
||||
|
||||
async function checkServers() {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const enabledSettings = getAllAutoShutdownSettings();
|
||||
|
||||
// Create a map for quick lookup
|
||||
const settingsMap = new Map(enabledSettings.map(s => [s.server_id, s]));
|
||||
|
||||
for (const server of config.servers) {
|
||||
const settings = settingsMap.get(server.id);
|
||||
|
||||
// Skip if auto-shutdown not enabled for this server
|
||||
if (!settings || !settings.enabled) {
|
||||
emptyPlayersSince.delete(server.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if server is online
|
||||
const status = await getServerStatus(server);
|
||||
|
||||
if (status !== 'online') {
|
||||
// Server not running, clear timer
|
||||
emptyPlayersSince.delete(server.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get player count
|
||||
let playerCount = 0;
|
||||
if (server.rconPassword) {
|
||||
const players = await getPlayers(server);
|
||||
playerCount = players.online || 0;
|
||||
}
|
||||
|
||||
if (playerCount === 0) {
|
||||
// No players online
|
||||
if (!emptyPlayersSince.has(server.id)) {
|
||||
// Start tracking empty time
|
||||
emptyPlayersSince.set(server.id, Date.now());
|
||||
console.log(`[AutoShutdown] ${server.id}: Keine Spieler online, Timer gestartet`);
|
||||
}
|
||||
|
||||
const emptyMs = Date.now() - emptyPlayersSince.get(server.id);
|
||||
const emptyMinutes = emptyMs / 60000;
|
||||
|
||||
if (emptyMinutes >= settings.timeout_minutes) {
|
||||
console.log(`[AutoShutdown] ${server.id}: Timeout erreicht (${settings.timeout_minutes} Min), stoppe Server...`);
|
||||
await stopServer(server);
|
||||
emptyPlayersSince.delete(server.id);
|
||||
console.log(`[AutoShutdown] ${server.id}: Server gestoppt`);
|
||||
}
|
||||
} else {
|
||||
// Players online, reset timer
|
||||
if (emptyPlayersSince.has(server.id)) {
|
||||
console.log(`[AutoShutdown] ${server.id}: Spieler online (${playerCount}), Timer zurückgesetzt`);
|
||||
emptyPlayersSince.delete(server.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[AutoShutdown] Fehler bei ${server.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AutoShutdown] Fehler beim Laden der Config:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function startAutoShutdownService() {
|
||||
console.log('[AutoShutdown] Service gestartet, prüfe alle 60 Sekunden');
|
||||
|
||||
// Initial check after 10 seconds (give server time to start)
|
||||
setTimeout(() => {
|
||||
checkServers();
|
||||
}, 10000);
|
||||
|
||||
// Then check every 60 seconds
|
||||
setInterval(checkServers, CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
// Get how long a server has been empty (for status display)
|
||||
export function getEmptySince(serverId) {
|
||||
const since = emptyPlayersSince.get(serverId);
|
||||
if (!since) return null;
|
||||
return Math.floor((Date.now() - since) / 60000); // Return minutes
|
||||
}
|
||||
|
||||
// Get all empty-since times
|
||||
export function getAllEmptySince() {
|
||||
const result = {};
|
||||
for (const [serverId, since] of emptyPlayersSince) {
|
||||
result[serverId] = Math.floor((Date.now() - since) / 60000);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
167
gsm-backend/services/discord.js
Normal file
167
gsm-backend/services/discord.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// Discord OAuth2 Service
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
|
||||
// Lazy initialization - wird erst bei Verwendung geladen (nach dotenv)
|
||||
let _guildConfigs = null;
|
||||
|
||||
function getGuildConfigs() {
|
||||
if (_guildConfigs === null) {
|
||||
_guildConfigs = [
|
||||
{
|
||||
name: 'Bacanaks',
|
||||
guildId: process.env.DISCORD_GUILD_ID_1,
|
||||
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_1,
|
||||
modRoleId: process.env.DISCORD_MOD_ROLE_ID_1
|
||||
},
|
||||
{
|
||||
name: 'Piccadilly',
|
||||
guildId: process.env.DISCORD_GUILD_ID_2,
|
||||
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_2,
|
||||
modRoleId: process.env.DISCORD_MOD_ROLE_ID_2
|
||||
}
|
||||
].filter(config => config.guildId);
|
||||
}
|
||||
return _guildConfigs;
|
||||
}
|
||||
|
||||
export function getDiscordAuthUrl() {
|
||||
const params = new URLSearchParams({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
redirect_uri: process.env.DISCORD_REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: 'identify guilds.members.read'
|
||||
});
|
||||
return `https://discord.com/oauth2/authorize?${params}`;
|
||||
}
|
||||
|
||||
export async function exchangeCode(code) {
|
||||
const response = await fetch(`${DISCORD_API}/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: process.env.DISCORD_REDIRECT_URI
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Failed to exchange code: ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getDiscordUser(accessToken) {
|
||||
const response = await fetch(`${DISCORD_API}/users/@me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get Discord user');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Prüft einen einzelnen Server
|
||||
async function fetchGuildMember(guildId, userId) {
|
||||
const response = await fetch(
|
||||
`${DISCORD_API}/guilds/${guildId}/members/${userId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Failed to get guild member from ${guildId}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Prüft alle konfigurierten Server und gibt Memberships zurück
|
||||
export async function getGuildMemberships(userId) {
|
||||
const configs = getGuildConfigs();
|
||||
const memberships = [];
|
||||
|
||||
for (const config of configs) {
|
||||
try {
|
||||
const member = await fetchGuildMember(config.guildId, userId);
|
||||
if (member) {
|
||||
memberships.push({
|
||||
config,
|
||||
member,
|
||||
roles: member.roles || []
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Discord] Failed to check membership for guild ${config.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return memberships.length > 0 ? memberships : null;
|
||||
}
|
||||
|
||||
// Legacy-Funktion für Kompatibilität
|
||||
export async function getGuildMember(userId) {
|
||||
const memberships = await getGuildMemberships(userId);
|
||||
if (!memberships || memberships.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return memberships[0].member;
|
||||
}
|
||||
|
||||
// Rollen-Priorität: superadmin > moderator > user
|
||||
const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 };
|
||||
|
||||
// Bestimmt die höchste Rolle aus allen Server-Memberships
|
||||
export function getUserRoleFromMemberships(memberships) {
|
||||
if (!memberships || memberships.length === 0) {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
let highestRole = 'user';
|
||||
|
||||
for (const { config, roles } of memberships) {
|
||||
let role = 'user';
|
||||
|
||||
if (roles.includes(config.adminRoleId)) {
|
||||
role = 'superadmin';
|
||||
} else if (roles.includes(config.modRoleId)) {
|
||||
role = 'moderator';
|
||||
}
|
||||
|
||||
if (ROLE_PRIORITY[role] > ROLE_PRIORITY[highestRole]) {
|
||||
highestRole = role;
|
||||
}
|
||||
}
|
||||
|
||||
return highestRole;
|
||||
}
|
||||
|
||||
// Legacy-Funktion für Kompatibilität
|
||||
export function getUserRole(memberRoles) {
|
||||
const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID || process.env.DISCORD_ADMIN_ROLE_ID_1;
|
||||
const modRoleId = process.env.DISCORD_MOD_ROLE_ID || process.env.DISCORD_MOD_ROLE_ID_1;
|
||||
|
||||
if (memberRoles.includes(adminRoleId)) {
|
||||
return 'superadmin';
|
||||
}
|
||||
if (memberRoles.includes(modRoleId)) {
|
||||
return 'moderator';
|
||||
}
|
||||
return 'user';
|
||||
}
|
||||
@@ -20,7 +20,9 @@ const serverDisplay = {
|
||||
factorio: { name: 'Factorio', icon: '⚙️', color: 0xF97316, address: 'factorio.zeasy.dev' },
|
||||
zomboid: { name: 'Project Zomboid', icon: '🧟', color: 0x4ADE80, address: 'pz.zeasy.dev:16261' },
|
||||
vrising: { name: 'V Rising', icon: '🧛', color: 0xDC2626, address: 'vrising.zeasy.dev' },
|
||||
palworld: { name: 'Palworld', icon: '🦎', color: 0x00D4AA, address: 'palworld.zeasy.dev:8211' }
|
||||
palworld: { name: 'Palworld', icon: '🦎', color: 0x00D4AA, address: 'palworld.zeasy.dev:8211' },
|
||||
terraria: { name: 'Terraria', icon: '⚔️', color: 0x05C46B, address: 'terraria.zeasy.dev:7777' },
|
||||
openttd: { name: 'OpenTTD', icon: '🚂', color: 0x1E90FF, address: 'openttd.zeasy.dev:3979' }
|
||||
};
|
||||
|
||||
function loadConfig() {
|
||||
@@ -497,17 +499,28 @@ export async function initDiscordBot() {
|
||||
console.log('[DiscordBot] Left guild: ' + guild.name + ' (' + guild.id + ')');
|
||||
deleteGuildSettings(guild.id);
|
||||
});
|
||||
|
||||
client.once('ready', async () => {
|
||||
console.log('[DiscordBot] Logged in as ' + client.user.tag);
|
||||
|
||||
// Check for guilds without settings and set them up
|
||||
for (const [guildId, guild] of client.guilds.cache) {
|
||||
const settings = getGuildSettings(guildId);
|
||||
if (!settings) {
|
||||
console.log('[DiscordBot] Setting up missing guild: ' + guild.name);
|
||||
try {
|
||||
await setupGuildChannels(guild);
|
||||
} catch (err) {
|
||||
console.error('[DiscordBot] Failed to setup missing guild ' + guild.name + ':', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First run - populate state without alerts
|
||||
await updateAllStatusMessages(true);
|
||||
|
||||
// Regular updates every 60 seconds
|
||||
setInterval(() => updateAllStatusMessages(false), 60000);
|
||||
});
|
||||
|
||||
client.login(token).catch(err => {
|
||||
console.error('[DiscordBot] Failed to login:', err.message);
|
||||
});
|
||||
|
||||
99
gsm-backend/services/factorio.js
Normal file
99
gsm-backend/services/factorio.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
|
||||
}
|
||||
|
||||
export function getFactorioServer() {
|
||||
const config = loadConfig();
|
||||
return config.servers.find(s => s.type === "factorio");
|
||||
}
|
||||
|
||||
// Default map-gen-settings structure
|
||||
export function getDefaultMapGenSettings() {
|
||||
return {
|
||||
terrain_segmentation: 1,
|
||||
water: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
starting_area: 1,
|
||||
peaceful_mode: false,
|
||||
autoplace_controls: {
|
||||
coal: { frequency: 1, size: 1, richness: 1 },
|
||||
stone: { frequency: 1, size: 1, richness: 1 },
|
||||
"copper-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"iron-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"uranium-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"crude-oil": { frequency: 1, size: 1, richness: 1 },
|
||||
trees: { frequency: 1, size: 1, richness: 1 },
|
||||
"enemy-base": { frequency: 1, size: 1, richness: 1 }
|
||||
},
|
||||
cliff_settings: {
|
||||
name: "cliff",
|
||||
cliff_elevation_0: 10,
|
||||
cliff_elevation_interval: 40,
|
||||
richness: 1
|
||||
},
|
||||
seed: null
|
||||
};
|
||||
}
|
||||
|
||||
// Factorio presets
|
||||
export const FACTORIO_PRESETS = {
|
||||
default: getDefaultMapGenSettings(),
|
||||
"rich-resources": {
|
||||
...getDefaultMapGenSettings(),
|
||||
autoplace_controls: {
|
||||
coal: { frequency: 1, size: 1, richness: 2 },
|
||||
stone: { frequency: 1, size: 1, richness: 2 },
|
||||
"copper-ore": { frequency: 1, size: 1, richness: 2 },
|
||||
"iron-ore": { frequency: 1, size: 1, richness: 2 },
|
||||
"uranium-ore": { frequency: 1, size: 1, richness: 2 },
|
||||
"crude-oil": { frequency: 1, size: 1, richness: 2 },
|
||||
trees: { frequency: 1, size: 1, richness: 1 },
|
||||
"enemy-base": { frequency: 1, size: 1, richness: 1 }
|
||||
}
|
||||
},
|
||||
"rail-world": {
|
||||
...getDefaultMapGenSettings(),
|
||||
autoplace_controls: {
|
||||
coal: { frequency: 0.33, size: 3, richness: 1 },
|
||||
stone: { frequency: 0.33, size: 3, richness: 1 },
|
||||
"copper-ore": { frequency: 0.33, size: 3, richness: 1 },
|
||||
"iron-ore": { frequency: 0.33, size: 3, richness: 1 },
|
||||
"uranium-ore": { frequency: 0.33, size: 3, richness: 1 },
|
||||
"crude-oil": { frequency: 0.33, size: 3, richness: 1 },
|
||||
trees: { frequency: 1, size: 1, richness: 1 },
|
||||
"enemy-base": { frequency: 0.5, size: 1, richness: 1 }
|
||||
}
|
||||
},
|
||||
"death-world": {
|
||||
...getDefaultMapGenSettings(),
|
||||
autoplace_controls: {
|
||||
coal: { frequency: 1, size: 1, richness: 1 },
|
||||
stone: { frequency: 1, size: 1, richness: 1 },
|
||||
"copper-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"iron-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"uranium-ore": { frequency: 1, size: 1, richness: 1 },
|
||||
"crude-oil": { frequency: 1, size: 1, richness: 1 },
|
||||
trees: { frequency: 1, size: 1, richness: 1 },
|
||||
"enemy-base": { frequency: 2, size: 2, richness: 1 }
|
||||
}
|
||||
},
|
||||
peaceful: {
|
||||
...getDefaultMapGenSettings(),
|
||||
peaceful_mode: true
|
||||
}
|
||||
};
|
||||
|
||||
export function getPresetNames() {
|
||||
return Object.keys(FACTORIO_PRESETS);
|
||||
}
|
||||
|
||||
export function getPreset(name) {
|
||||
return FACTORIO_PRESETS[name] || FACTORIO_PRESETS.default;
|
||||
}
|
||||
112
gsm-backend/services/prometheus.js
Normal file
112
gsm-backend/services/prometheus.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import fetch from "node-fetch";
|
||||
|
||||
const PROMETHEUS_URL = "http://localhost:9090";
|
||||
|
||||
const SERVER_JOBS = {
|
||||
"vrising": "vrising",
|
||||
"factorio": "factorio",
|
||||
"minecraft": "minecraft",
|
||||
"zomboid": "zomboid",
|
||||
"palworld": "palworld",
|
||||
"terraria": "terraria",
|
||||
"openttd": "openttd"
|
||||
};
|
||||
|
||||
export async function queryPrometheus(query) {
|
||||
const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.status !== "success") {
|
||||
throw new Error(`Prometheus query failed: ${data.error}`);
|
||||
}
|
||||
return data.data.result;
|
||||
}
|
||||
|
||||
export async function queryPrometheusRange(query, start, end, step) {
|
||||
const url = `${PROMETHEUS_URL}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${start}&end=${end}&step=${step}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (data.status !== "success") {
|
||||
throw new Error(`Prometheus query failed: ${data.error}`);
|
||||
}
|
||||
return data.data.result;
|
||||
}
|
||||
|
||||
export async function getServerMetricsHistory(serverId, range = "1h") {
|
||||
const job = SERVER_JOBS[serverId];
|
||||
if (!job) {
|
||||
throw new Error(`Unknown server ID: ${serverId}`);
|
||||
}
|
||||
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
let duration, step;
|
||||
switch (range) {
|
||||
case "15m": duration = 15 * 60; step = 15; break;
|
||||
case "1h": duration = 60 * 60; step = 60; break;
|
||||
case "6h": duration = 6 * 60 * 60; step = 300; break;
|
||||
case "24h": duration = 24 * 60 * 60; step = 900; break;
|
||||
default: duration = 60 * 60; step = 60;
|
||||
}
|
||||
const start = end - duration;
|
||||
|
||||
const cpuQuery = `100 - (avg by(instance) (irate(node_cpu_seconds_total{job="${job}",mode="idle"}[5m])) * 100)`;
|
||||
const memQuery = `100 * (1 - ((node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"}) / node_memory_MemTotal_bytes{job="${job}"}))`;
|
||||
const netRxQuery = `sum(irate(node_network_receive_bytes_total{job="${job}",device!~"lo|veth.*|docker.*|br-.*"}[5m]))`;
|
||||
const netTxQuery = `sum(irate(node_network_transmit_bytes_total{job="${job}",device!~"lo|veth.*|docker.*|br-.*"}[5m]))`;
|
||||
|
||||
try {
|
||||
const [cpuResult, memResult, netRxResult, netTxResult] = await Promise.all([
|
||||
queryPrometheusRange(cpuQuery, start, end, step),
|
||||
queryPrometheusRange(memQuery, start, end, step),
|
||||
queryPrometheusRange(netRxQuery, start, end, step),
|
||||
queryPrometheusRange(netTxQuery, start, end, step)
|
||||
]);
|
||||
|
||||
const cpu = cpuResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
|
||||
const memory = memResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
|
||||
const networkRx = netRxResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
|
||||
const networkTx = netTxResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
|
||||
|
||||
return { cpu, memory, networkRx, networkTx };
|
||||
} catch (error) {
|
||||
console.error("Prometheus query error:", error);
|
||||
return { cpu: [], memory: [], networkRx: [], networkTx: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentMetrics(serverId) {
|
||||
const job = SERVER_JOBS[serverId];
|
||||
if (!job) {
|
||||
throw new Error(`Unknown server ID: ${serverId}`);
|
||||
}
|
||||
|
||||
const cpuQuery = `100 - (avg by(instance) (irate(node_cpu_seconds_total{job="${job}",mode="idle"}[5m])) * 100)`;
|
||||
const memPercentQuery = `100 * (1 - ((node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"}) / node_memory_MemTotal_bytes{job="${job}"}))`;
|
||||
const memUsedQuery = `node_memory_MemTotal_bytes{job="${job}"} - (node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"})`;
|
||||
const memTotalQuery = `node_memory_MemTotal_bytes{job="${job}"}`;
|
||||
const uptimeQuery = `node_time_seconds{job="${job}"} - node_boot_time_seconds{job="${job}"}`;
|
||||
const cpuCoresQuery = `count(node_cpu_seconds_total{job="${job}",mode="idle"})`;
|
||||
|
||||
try {
|
||||
const [cpuResult, memPercentResult, memUsedResult, memTotalResult, uptimeResult, cpuCoresResult] = await Promise.all([
|
||||
queryPrometheus(cpuQuery),
|
||||
queryPrometheus(memPercentQuery),
|
||||
queryPrometheus(memUsedQuery),
|
||||
queryPrometheus(memTotalQuery),
|
||||
queryPrometheus(uptimeQuery),
|
||||
queryPrometheus(cpuCoresQuery)
|
||||
]);
|
||||
|
||||
return {
|
||||
cpu: parseFloat(cpuResult[0]?.value?.[1]) || 0,
|
||||
memory: parseFloat(memPercentResult[0]?.value?.[1]) || 0,
|
||||
memoryUsed: parseFloat(memUsedResult[0]?.value?.[1]) || 0,
|
||||
memoryTotal: parseFloat(memTotalResult[0]?.value?.[1]) || 0,
|
||||
uptime: parseFloat(uptimeResult[0]?.value?.[1]) || 0,
|
||||
cpuCores: parseInt(cpuCoresResult[0]?.value?.[1]) || 1
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Prometheus current metrics error:", error);
|
||||
return { cpu: 0, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0, cpuCores: 1 };
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,18 @@ export async function getServerStatus(server) {
|
||||
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 {
|
||||
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (result.code === 0) {
|
||||
@@ -126,7 +138,10 @@ export async function startServer(server, options = {}) {
|
||||
await ssh.execCommand(`docker start ${server.containerName}`);
|
||||
}
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl start ${server.serviceName}`);
|
||||
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 {
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
@@ -140,7 +155,10 @@ export async function stopServer(server) {
|
||||
if (server.runtime === 'docker') {
|
||||
await ssh.execCommand(`docker stop ${server.containerName}`);
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl stop ${server.serviceName}`);
|
||||
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 {
|
||||
// Different stop commands per server type
|
||||
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
|
||||
@@ -176,6 +194,10 @@ export async function getConsoleLog(server, lines = 50) {
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.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.logFile) {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
@@ -203,6 +225,17 @@ export async function getProcessUptime(server) {
|
||||
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 {
|
||||
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());
|
||||
@@ -491,3 +524,67 @@ export async function writePalworldConfig(server, filename, content) {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ TERRARIA CONFIG ============
|
||||
const TERRARIA_CONFIG_PATH = "/home/terraria/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/${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/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;
|
||||
}
|
||||
|
||||
493
gsm-backend/services/ssh.js.bak
Normal file
493
gsm-backend/services/ssh.js.bak
Normal file
@@ -0,0 +1,493 @@
|
||||
import { NodeSSH } from "node-ssh";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const sshConnections = new Map();
|
||||
const failedHosts = new Map(); // Cache failed connections
|
||||
const FAILED_HOST_TTL = 60000; // 60 seconds before retry
|
||||
const SSH_TIMEOUT = 5000;
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
|
||||
}
|
||||
|
||||
// Check if host is marked as failed (non-blocking)
|
||||
export function isHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
const failedAt = failedHosts.get(key);
|
||||
return failedAt && Date.now() - failedAt < FAILED_HOST_TTL;
|
||||
}
|
||||
|
||||
// Mark host as failed
|
||||
export function markHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.set(key, Date.now());
|
||||
}
|
||||
|
||||
// Clear failed status
|
||||
export function clearHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.delete(key);
|
||||
}
|
||||
|
||||
async function getConnection(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
|
||||
// Check if host recently failed - throw immediately
|
||||
if (isHostFailed(host, username)) {
|
||||
throw new Error("Host recently unreachable");
|
||||
}
|
||||
|
||||
if (sshConnections.has(key)) {
|
||||
const conn = sshConnections.get(key);
|
||||
if (conn.isConnected()) return conn;
|
||||
sshConnections.delete(key);
|
||||
}
|
||||
|
||||
const ssh = new NodeSSH();
|
||||
try {
|
||||
await ssh.connect({
|
||||
host,
|
||||
username,
|
||||
privateKeyPath: "/root/.ssh/id_ed25519",
|
||||
readyTimeout: SSH_TIMEOUT
|
||||
});
|
||||
clearHostFailed(host, username);
|
||||
sshConnections.set(key, ssh);
|
||||
return ssh;
|
||||
} catch (err) {
|
||||
markHostFailed(host, username);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns: "online", "starting", "stopping", "offline"
|
||||
export async function getServerStatus(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'running') return 'online';
|
||||
if (status === 'restarting' || status === 'created') return 'starting';
|
||||
if (status === 'removing' || status === 'paused') return 'stopping';
|
||||
return 'offline';
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'active') return 'online';
|
||||
if (status === 'activating' || status === 'reloading') return 'starting';
|
||||
if (status === 'deactivating') return 'stopping';
|
||||
return 'offline';
|
||||
} else {
|
||||
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (result.code === 0) {
|
||||
const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(uptimeResult.stdout.trim()) || 999;
|
||||
if (uptime < 60) return 'starting';
|
||||
return 'online';
|
||||
}
|
||||
return 'offline';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to get status for ${server.id}:`, err.message);
|
||||
return 'offline';
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServer(server, options = {}) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
// Factorio with specific save
|
||||
if (server.type === 'factorio' && options.save) {
|
||||
const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save;
|
||||
// Stop and remove existing container
|
||||
await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`);
|
||||
await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`);
|
||||
// Start with specific save
|
||||
await ssh.execCommand(`
|
||||
docker run -d \
|
||||
--name ${server.containerName} \
|
||||
-p 34197:34197/udp \
|
||||
-p 27015:27015/tcp \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
-e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \
|
||||
-e TZ=Europe/Berlin \
|
||||
-e PUID=845 \
|
||||
-e PGID=845 \
|
||||
--restart=unless-stopped \
|
||||
factoriotools/factorio
|
||||
`);
|
||||
} else {
|
||||
await ssh.execCommand(`docker start ${server.containerName}`);
|
||||
}
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl start ${server.serviceName}`);
|
||||
} else {
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopServer(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
await ssh.execCommand(`docker stop ${server.containerName}`);
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl stop ${server.serviceName}`);
|
||||
} else {
|
||||
// Different stop commands per server type
|
||||
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`);
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (check.code !== 0) {
|
||||
console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Force killing ${server.id} after timeout`);
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit`);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartServer(server) {
|
||||
await stopServer(server);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await startServer(server);
|
||||
}
|
||||
|
||||
export async function getConsoleLog(server, lines = 50) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.logFile) {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
} else {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProcessUptime(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === "docker") {
|
||||
const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`);
|
||||
if (result.stdout.trim()) {
|
||||
const startTime = new Date(result.stdout.trim());
|
||||
if (!isNaN(startTime.getTime())) {
|
||||
return Math.floor((Date.now() - startTime.getTime()) / 1000);
|
||||
}
|
||||
}
|
||||
} else if (server.runtime === "systemd") {
|
||||
const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`);
|
||||
const startEpoch = parseInt(result.stdout.trim());
|
||||
if (!isNaN(startEpoch)) {
|
||||
return Math.floor(Date.now() / 1000) - startEpoch;
|
||||
}
|
||||
} else {
|
||||
const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(result.stdout.trim());
|
||||
if (!isNaN(uptime)) return uptime;
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get uptime for ${server.id}:`, err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ FACTORIO-SPECIFIC FUNCTIONS ============
|
||||
|
||||
export async function listFactorioSaves(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null');
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const saves = [];
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const filename = match[7].split('/').pop();
|
||||
// Skip autosaves
|
||||
if (filename.startsWith('_autosave')) continue;
|
||||
|
||||
const size = match[5];
|
||||
const modified = match[6];
|
||||
|
||||
saves.push({
|
||||
name: filename.replace('.zip', ''),
|
||||
filename,
|
||||
size,
|
||||
modified
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return saves;
|
||||
}
|
||||
|
||||
export async function deleteFactorioSave(server, saveName) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`;
|
||||
|
||||
// Security check - prevent path traversal
|
||||
if (filename.includes('/') || filename.includes('..')) {
|
||||
throw new Error('Invalid save name');
|
||||
}
|
||||
|
||||
const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to delete save');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFactorioWorld(server, saveName, settings) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Security check
|
||||
if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) {
|
||||
throw new Error("Invalid save name - no spaces or special characters allowed");
|
||||
}
|
||||
|
||||
// Write map-gen-settings.json
|
||||
const settingsJson = JSON.stringify(settings, null, 2);
|
||||
await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF'
|
||||
${settingsJson}
|
||||
SETTINGSEOF`);
|
||||
|
||||
// Create new world using --create flag (correct method)
|
||||
console.log(`Creating new Factorio world: ${saveName}`);
|
||||
const createResult = await ssh.execCommand(`
|
||||
docker run --rm \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
factoriotools/factorio \
|
||||
/opt/factorio/bin/x64/factorio \
|
||||
--create /factorio/saves/${saveName}.zip \
|
||||
--map-gen-settings /factorio/config/map-gen-settings.json
|
||||
`, { execOptions: { timeout: 120000 } });
|
||||
|
||||
console.log("World creation output:", createResult.stdout, createResult.stderr);
|
||||
|
||||
// Verify save was created
|
||||
const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`);
|
||||
if (checkResult.code !== 0) {
|
||||
throw new Error("World creation failed - save file not found. Output: " + createResult.stderr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFactorioCurrentSave(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Check if container has SAVE_NAME env set
|
||||
const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`);
|
||||
if (envResult.stdout.trim()) {
|
||||
const saveName = envResult.stdout.trim().replace("SAVE_NAME=", "");
|
||||
return { save: saveName, source: "configured" };
|
||||
}
|
||||
|
||||
// Otherwise find newest save file
|
||||
const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1");
|
||||
if (result.stdout.trim()) {
|
||||
const filename = result.stdout.trim().split("/").pop().replace(".zip", "");
|
||||
return { save: filename, source: "newest" };
|
||||
}
|
||||
|
||||
return { save: null, source: null };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============ ZOMBOID CONFIG FUNCTIONS ============
|
||||
|
||||
const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server";
|
||||
const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"];
|
||||
|
||||
export async function listZomboidConfigs(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readZomboidConfig(server, filename) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writeZomboidConfig(server, filename, content) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ PALWORLD CONFIG ============
|
||||
const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer";
|
||||
const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"];
|
||||
|
||||
export async function listPalworldConfigs(server) {
|
||||
const ssh = await getConnection(server.host);
|
||||
const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readPalworldConfig(server, filename) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writePalworldConfig(server, filename, content) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
493
gsm-backend/services/ssh.js.bak2
Normal file
493
gsm-backend/services/ssh.js.bak2
Normal file
@@ -0,0 +1,493 @@
|
||||
import { NodeSSH } from "node-ssh";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const sshConnections = new Map();
|
||||
const failedHosts = new Map(); // Cache failed connections
|
||||
const FAILED_HOST_TTL = 60000; // 60 seconds before retry
|
||||
const SSH_TIMEOUT = 5000;
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
|
||||
}
|
||||
|
||||
// Check if host is marked as failed (non-blocking)
|
||||
export function isHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
const failedAt = failedHosts.get(key);
|
||||
return failedAt && Date.now() - failedAt < FAILED_HOST_TTL;
|
||||
}
|
||||
|
||||
// Mark host as failed
|
||||
export function markHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.set(key, Date.now());
|
||||
}
|
||||
|
||||
// Clear failed status
|
||||
export function clearHostFailed(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
failedHosts.delete(key);
|
||||
}
|
||||
|
||||
async function getConnection(host, username = "root") {
|
||||
const key = username + "@" + host;
|
||||
|
||||
// Check if host recently failed - throw immediately
|
||||
if (isHostFailed(host, username)) {
|
||||
throw new Error("Host recently unreachable");
|
||||
}
|
||||
|
||||
if (sshConnections.has(key)) {
|
||||
const conn = sshConnections.get(key);
|
||||
if (conn.isConnected()) return conn;
|
||||
sshConnections.delete(key);
|
||||
}
|
||||
|
||||
const ssh = new NodeSSH();
|
||||
try {
|
||||
await ssh.connect({
|
||||
host,
|
||||
username,
|
||||
privateKeyPath: "/root/.ssh/id_ed25519",
|
||||
readyTimeout: SSH_TIMEOUT
|
||||
});
|
||||
clearHostFailed(host, username);
|
||||
sshConnections.set(key, ssh);
|
||||
return ssh;
|
||||
} catch (err) {
|
||||
markHostFailed(host, username);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns: "online", "starting", "stopping", "offline"
|
||||
export async function getServerStatus(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'running') return 'online';
|
||||
if (status === 'restarting' || status === 'created') return 'starting';
|
||||
if (status === 'removing' || status === 'paused') return 'stopping';
|
||||
return 'offline';
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`);
|
||||
const status = result.stdout.trim();
|
||||
if (status === 'active') return 'online';
|
||||
if (status === 'activating' || status === 'reloading') return 'starting';
|
||||
if (status === 'deactivating') return 'stopping';
|
||||
return 'offline';
|
||||
} else {
|
||||
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (result.code === 0) {
|
||||
const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(uptimeResult.stdout.trim()) || 999;
|
||||
if (uptime < 60) return 'starting';
|
||||
return 'online';
|
||||
}
|
||||
return 'offline';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to get status for ${server.id}:`, err.message);
|
||||
return 'offline';
|
||||
}
|
||||
}
|
||||
|
||||
export async function startServer(server, options = {}) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
// Factorio with specific save
|
||||
if (server.type === 'factorio' && options.save) {
|
||||
const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save;
|
||||
// Stop and remove existing container
|
||||
await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`);
|
||||
await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`);
|
||||
// Start with specific save
|
||||
await ssh.execCommand(`
|
||||
docker run -d \
|
||||
--name ${server.containerName} \
|
||||
-p 34197:34197/udp \
|
||||
-p 27015:27015/tcp \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
-e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \
|
||||
-e TZ=Europe/Berlin \
|
||||
-e PUID=845 \
|
||||
-e PGID=845 \
|
||||
--restart=unless-stopped \
|
||||
factoriotools/factorio
|
||||
`);
|
||||
} else {
|
||||
await ssh.execCommand(`docker start ${server.containerName}`);
|
||||
}
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl start ${server.serviceName}`);
|
||||
} else {
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopServer(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
await ssh.execCommand(`docker stop ${server.containerName}`);
|
||||
} else if (server.runtime === 'systemd') {
|
||||
await ssh.execCommand(`systemctl stop ${server.serviceName}`);
|
||||
} else {
|
||||
// Different stop commands per server type
|
||||
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`);
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
|
||||
if (check.code !== 0) {
|
||||
console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Force killing ${server.id} after timeout`);
|
||||
await ssh.execCommand(`screen -S ${server.screenName} -X quit`);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartServer(server) {
|
||||
await stopServer(server);
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
await startServer(server);
|
||||
}
|
||||
|
||||
export async function getConsoleLog(server, lines = 50) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === 'docker') {
|
||||
const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.runtime === 'systemd') {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
|
||||
return result.stdout || result.stderr;
|
||||
} else if (server.logFile) {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
} else {
|
||||
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`);
|
||||
return result.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProcessUptime(server) {
|
||||
try {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
if (server.runtime === "docker") {
|
||||
const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`);
|
||||
if (result.stdout.trim()) {
|
||||
const startTime = new Date(result.stdout.trim());
|
||||
if (!isNaN(startTime.getTime())) {
|
||||
return Math.floor((Date.now() - startTime.getTime()) / 1000);
|
||||
}
|
||||
}
|
||||
} else if (server.runtime === "systemd") {
|
||||
const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`);
|
||||
const startEpoch = parseInt(result.stdout.trim());
|
||||
if (!isNaN(startEpoch)) {
|
||||
return Math.floor(Date.now() / 1000) - startEpoch;
|
||||
}
|
||||
} else {
|
||||
const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`);
|
||||
const uptime = parseInt(result.stdout.trim());
|
||||
if (!isNaN(uptime)) return uptime;
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get uptime for ${server.id}:`, err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ FACTORIO-SPECIFIC FUNCTIONS ============
|
||||
|
||||
export async function listFactorioSaves(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null');
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const saves = [];
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const filename = match[7].split('/').pop();
|
||||
// Skip autosaves
|
||||
if (filename.startsWith('_autosave')) continue;
|
||||
|
||||
const size = match[5];
|
||||
const modified = match[6];
|
||||
|
||||
saves.push({
|
||||
name: filename.replace('.zip', ''),
|
||||
filename,
|
||||
size,
|
||||
modified
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return saves;
|
||||
}
|
||||
|
||||
export async function deleteFactorioSave(server, saveName) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`;
|
||||
|
||||
// Security check - prevent path traversal
|
||||
if (filename.includes('/') || filename.includes('..')) {
|
||||
throw new Error('Invalid save name');
|
||||
}
|
||||
|
||||
const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to delete save');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createFactorioWorld(server, saveName, settings) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Security check
|
||||
if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) {
|
||||
throw new Error("Invalid save name - no spaces or special characters allowed");
|
||||
}
|
||||
|
||||
// Write map-gen-settings.json
|
||||
const settingsJson = JSON.stringify(settings, null, 2);
|
||||
await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF'
|
||||
${settingsJson}
|
||||
SETTINGSEOF`);
|
||||
|
||||
// Create new world using --create flag (correct method)
|
||||
console.log(`Creating new Factorio world: ${saveName}`);
|
||||
const createResult = await ssh.execCommand(`
|
||||
docker run --rm \
|
||||
-v /srv/docker/factorio/data:/factorio \
|
||||
factoriotools/factorio \
|
||||
/opt/factorio/bin/x64/factorio \
|
||||
--create /factorio/saves/${saveName}.zip \
|
||||
--map-gen-settings /factorio/config/map-gen-settings.json
|
||||
`, { execOptions: { timeout: 120000 } });
|
||||
|
||||
console.log("World creation output:", createResult.stdout, createResult.stderr);
|
||||
|
||||
// Verify save was created
|
||||
const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`);
|
||||
if (checkResult.code !== 0) {
|
||||
throw new Error("World creation failed - save file not found. Output: " + createResult.stderr);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFactorioCurrentSave(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Check if container has SAVE_NAME env set
|
||||
const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`);
|
||||
if (envResult.stdout.trim()) {
|
||||
const saveName = envResult.stdout.trim().replace("SAVE_NAME=", "");
|
||||
return { save: saveName, source: "configured" };
|
||||
}
|
||||
|
||||
// Otherwise find newest save file
|
||||
const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1");
|
||||
if (result.stdout.trim()) {
|
||||
const filename = result.stdout.trim().split("/").pop().replace(".zip", "");
|
||||
return { save: filename, source: "newest" };
|
||||
}
|
||||
|
||||
return { save: null, source: null };
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============ ZOMBOID CONFIG FUNCTIONS ============
|
||||
|
||||
const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server";
|
||||
const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"];
|
||||
|
||||
export async function listZomboidConfigs(server) {
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readZomboidConfig(server, filename) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writeZomboidConfig(server, filename, content) {
|
||||
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host, server.sshUser);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ PALWORLD CONFIG ============
|
||||
const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer";
|
||||
const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"];
|
||||
|
||||
export async function listPalworldConfigs(server) {
|
||||
const ssh = await getConnection(server.host);
|
||||
const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`;
|
||||
const result = await ssh.execCommand(cmd);
|
||||
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const lines = result.stdout.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const fullPath = match[7];
|
||||
const filename = fullPath.split("/").pop();
|
||||
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue;
|
||||
|
||||
files.push({
|
||||
filename,
|
||||
size: parseInt(match[5]),
|
||||
modified: match[6]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function readPalworldConfig(server, filename) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`);
|
||||
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr || "Failed to read config file");
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function writePalworldConfig(server, filename, content) {
|
||||
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
|
||||
throw new Error("File not allowed");
|
||||
}
|
||||
if (filename.includes("/") || filename.includes("..")) {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
const ssh = await getConnection(server.host);
|
||||
|
||||
// Create backup
|
||||
const backupName = `${filename}.backup.${Date.now()}`;
|
||||
await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
|
||||
|
||||
// Write file using sftp
|
||||
const sftp = await ssh.requestSFTP();
|
||||
const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
sftp.writeFile(filePath, content, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up old backups (keep last 5)
|
||||
await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
|
||||
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user