palworld added
This commit is contained in:
677
gsm-backend/routes/servers.js
Normal file
677
gsm-backend/routes/servers.js
Normal file
@@ -0,0 +1,677 @@
|
||||
import { Router } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js';
|
||||
import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig } from '../services/ssh.js';
|
||||
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
|
||||
import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js';
|
||||
import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings } from '../db/init.js';
|
||||
import { getEmptySince } from '../services/autoshutdown.js';
|
||||
import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function loadConfig() {
|
||||
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
|
||||
}
|
||||
|
||||
// Initialize tables
|
||||
initWhitelistCache();
|
||||
initActivityLog();
|
||||
initFactorioTemplates();
|
||||
initFactorioWorldSettings();
|
||||
initServerDisplaySettings();
|
||||
|
||||
const router = Router();
|
||||
|
||||
function formatBytes(bytes, forceUnit = null) {
|
||||
if (bytes === 0) return { value: 0, unit: forceUnit || "B" };
|
||||
const gb = bytes / (1024 * 1024 * 1024);
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (forceUnit === "GB") return { value: gb, unit: "GB" };
|
||||
if (forceUnit === "MB") return { value: mb, unit: "MB" };
|
||||
if (gb >= 1) return { value: gb, unit: "GB" };
|
||||
return { value: mb, unit: "MB" };
|
||||
}
|
||||
|
||||
// ============ FACTORIO ROUTES ============
|
||||
|
||||
// Factorio: List saves
|
||||
router.get("/factorio/saves", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
const saves = await listFactorioSaves(server);
|
||||
res.json({ saves });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get presets and default settings
|
||||
router.get("/factorio/presets", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const presets = getPresetNames();
|
||||
const defaultSettings = getDefaultMapGenSettings();
|
||||
res.json({ presets, defaultSettings });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get preset by name
|
||||
router.get("/factorio/presets/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const preset = getPreset(req.params.name);
|
||||
res.json({ settings: preset });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: List templates
|
||||
router.get("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const templates = getFactorioTemplates();
|
||||
res.json({ templates: templates.map(t => ({ ...t, settings: JSON.parse(t.settings) })) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Create template
|
||||
router.post("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const { name, settings } = req.body;
|
||||
if (!name || !settings) {
|
||||
return res.status(400).json({ error: "Name and settings required" });
|
||||
}
|
||||
const id = createFactorioTemplate(name, settings, req.user.id);
|
||||
res.json({ id, message: "Template created" });
|
||||
} catch (err) {
|
||||
if (err.message.includes("UNIQUE constraint")) {
|
||||
return res.status(400).json({ error: "Template name already exists" });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Delete template
|
||||
router.delete("/factorio/templates/:id", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const result = deleteFactorioTemplate(parseInt(req.params.id));
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Template not found" });
|
||||
}
|
||||
res.json({ message: "Template deleted" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Create new world
|
||||
router.post("/factorio/create-world", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const { saveName, settings } = req.body;
|
||||
if (!saveName) {
|
||||
return res.status(400).json({ error: "Save name required" });
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
const finalSettings = settings || getDefaultMapGenSettings();
|
||||
await createFactorioWorld(server, saveName, finalSettings);
|
||||
|
||||
// Save settings to database for later reference
|
||||
saveFactorioWorldSettings(saveName, finalSettings, req.user.id);
|
||||
logActivity(req.user.id, req.user.username, 'factorio_world_create', 'factorio', saveName, req.user.discordId, req.user.avatar);
|
||||
|
||||
res.json({ message: "World created", saveName });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Delete save
|
||||
router.delete("/factorio/saves/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
await deleteFactorioSave(server, req.params.name);
|
||||
// Also delete stored settings if they exist
|
||||
deleteFactorioWorldSettings(req.params.name);
|
||||
logActivity(req.user.id, req.user.username, 'factorio_world_delete', 'factorio', req.params.name, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: "Save deleted" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get world settings
|
||||
router.get("/factorio/saves/:name/settings", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const settings = getFactorioWorldSettings(req.params.name);
|
||||
if (!settings) {
|
||||
return res.json({
|
||||
legacy: true,
|
||||
message: "This is a legacy world created before settings tracking was implemented"
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
legacy: false,
|
||||
settings: JSON.parse(settings.settings),
|
||||
createdBy: settings.created_by,
|
||||
createdAt: settings.created_at
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Factorio: Get current/default save
|
||||
router.get("/factorio/current-save", authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "factorio");
|
||||
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
|
||||
|
||||
const result = await getFactorioCurrentSave(server);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ ZOMBOID CONFIG ROUTES ============
|
||||
|
||||
// Zomboid: List config files
|
||||
router.get("/zomboid/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "zomboid");
|
||||
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||
|
||||
const files = await listZomboidConfigs(server);
|
||||
res.json({ files });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Zomboid: Read config file
|
||||
router.get("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "zomboid");
|
||||
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||
|
||||
const content = await readZomboidConfig(server, req.params.filename);
|
||||
res.json({ filename: req.params.filename, content });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Zomboid: Write config file
|
||||
router.put("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "zomboid");
|
||||
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
|
||||
|
||||
const { content } = req.body;
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({ error: "Content required" });
|
||||
}
|
||||
|
||||
await writeZomboidConfig(server, req.params.filename, content);
|
||||
logActivity(req.user.id, req.user.username, 'zomboid_config', 'zomboid', req.params.filename, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: "Config saved", filename: req.params.filename });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Palworld: List config files
|
||||
router.get("/palworld/config", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "palworld");
|
||||
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||
const files = await listPalworldConfigs(server);
|
||||
res.json({ files });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Palworld: Read config file
|
||||
router.get("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "palworld");
|
||||
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||
const content = await readPalworldConfig(server, req.params.filename);
|
||||
res.json({ filename: req.params.filename, content });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Palworld: Write config file
|
||||
router.put("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.type === "palworld");
|
||||
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
|
||||
const { content } = req.body;
|
||||
if (content === undefined) {
|
||||
return res.status(400).json({ error: "Content required" });
|
||||
}
|
||||
await writePalworldConfig(server, req.params.filename, content);
|
||||
logActivity(req.user.id, req.user.username, "palworld_config", "palworld", req.params.filename, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: "Config saved", filename: req.params.filename });
|
||||
} catch (err) {
|
||||
if (err.message === "File not allowed") {
|
||||
return res.status(403).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============ GENERAL ROUTES ============
|
||||
|
||||
// Get all servers with status
|
||||
router.get('/', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const servers = await Promise.all(config.servers.map(async (server) => {
|
||||
// Quick check if host is unreachable - skip expensive operations
|
||||
const hostUnreachable = isHostFailed(server.host, server.sshUser);
|
||||
// If host is unreachable, return immediately with minimal data
|
||||
if (hostUnreachable) {
|
||||
const metrics = await getCurrentMetrics(server.id).catch(() => ({
|
||||
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||
}));
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status: "unreachable",
|
||||
running: false,
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: 0
|
||||
},
|
||||
players: { online: 0, max: null, list: [] },
|
||||
hasRcon: !!server.rconPassword
|
||||
};
|
||||
}
|
||||
|
||||
const [status, metrics, players, playerList, processUptime] = await Promise.all([
|
||||
getServerStatus(server),
|
||||
getCurrentMetrics(server.id).catch(() => ({
|
||||
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
|
||||
})),
|
||||
server.rconPassword ? getPlayers(server).catch(() => ({ online: 0, max: null })) : { online: 0, max: null },
|
||||
server.rconPassword ? getPlayerList(server).catch(() => ({ players: [] })) : { players: [] },
|
||||
getProcessUptime(server).catch(() => 0)
|
||||
]);
|
||||
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
|
||||
// Get auto-shutdown info
|
||||
const shutdownSettings = getAutoShutdownSettings(server.id);
|
||||
const emptySince = getEmptySince(server.id);
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status,
|
||||
running: status === 'online',
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: processUptime
|
||||
},
|
||||
players: {
|
||||
...players,
|
||||
list: playerList.players
|
||||
},
|
||||
hasRcon: !!server.rconPassword,
|
||||
autoShutdown: {
|
||||
enabled: shutdownSettings?.enabled === 1 || false,
|
||||
timeoutMinutes: shutdownSettings?.timeout_minutes || 15,
|
||||
emptySinceMinutes: emptySince
|
||||
}
|
||||
};
|
||||
}));
|
||||
|
||||
res.json(servers);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Activity Log (superadmin only)
|
||||
router.get('/activity-log', authenticateToken, requireRole('superadmin'), (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 100;
|
||||
const logs = getActivityLog(limit);
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all server display settings (for ServerCard)
|
||||
router.get("/display-settings", optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = getAllServerDisplaySettings();
|
||||
const result = {};
|
||||
settings.forEach(s => {
|
||||
result[s.server_id] = { address: s.address, hint: s.hint };
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single server
|
||||
router.get('/:id', optionalAuth, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) {
|
||||
return res.status(404).json({ error: 'Server not found' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [status, metrics, players, playerList, processUptime] = await Promise.all([
|
||||
getServerStatus(server),
|
||||
getCurrentMetrics(server.id),
|
||||
server.rconPassword ? getPlayers(server) : { online: 0, max: null },
|
||||
server.rconPassword ? getPlayerList(server) : { players: [] },
|
||||
getProcessUptime(server).catch(() => 0)
|
||||
]);
|
||||
|
||||
const memTotal = formatBytes(metrics.memoryTotal);
|
||||
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
|
||||
|
||||
res.json({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
type: server.type,
|
||||
status,
|
||||
running: status === 'online',
|
||||
metrics: {
|
||||
cpu: metrics.cpu,
|
||||
cpuCores: metrics.cpuCores,
|
||||
memory: metrics.memory,
|
||||
memoryUsed: memUsed.value,
|
||||
memoryTotal: memTotal.value,
|
||||
memoryUnit: memTotal.unit,
|
||||
uptime: processUptime
|
||||
},
|
||||
players: {
|
||||
...players,
|
||||
list: playerList.players
|
||||
},
|
||||
hasRcon: !!server.rconPassword
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get metrics history from Prometheus
|
||||
router.get('/:id/metrics/history', optionalAuth, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const range = req.query.range || '1h';
|
||||
const validRanges = ['15m', '1h', '6h', '24h'];
|
||||
if (!validRanges.includes(range)) {
|
||||
return res.status(400).json({ error: 'Invalid range. Valid: 15m, 1h, 6h, 24h' });
|
||||
}
|
||||
|
||||
try {
|
||||
const history = await getServerMetricsHistory(server.id, range);
|
||||
res.json(history);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get player list
|
||||
router.get('/:id/players', authenticateToken, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!server.rconPassword) {
|
||||
return res.status(400).json({ error: 'RCON not configured for this server' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [count, list] = await Promise.all([
|
||||
getPlayers(server),
|
||||
getPlayerList(server)
|
||||
]);
|
||||
res.json({ ...count, list: list.players });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get console logs (moderator+)
|
||||
router.get('/:id/logs', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
try {
|
||||
const lines = parseInt(req.query.lines) || 100;
|
||||
const logs = await getConsoleLog(server, lines);
|
||||
res.json({ logs });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Power actions (moderator+)
|
||||
router.post('/:id/start', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
try {
|
||||
const { save } = req.body || {};
|
||||
await startServer(server, { save });
|
||||
logActivity(req.user.id, req.user.username, 'server_start', server.id, save ? 'Save: ' + save : null, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: 'Server starting' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/stop', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
try {
|
||||
await stopServer(server);
|
||||
logActivity(req.user.id, req.user.username, 'server_stop', server.id, null, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: 'Server stopping' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/restart', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
try {
|
||||
await restartServer(server);
|
||||
logActivity(req.user.id, req.user.username, 'server_restart', server.id, null, req.user.discordId, req.user.avatar);
|
||||
res.json({ message: 'Server restarting' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get whitelist (with server-side caching)
|
||||
router.get('/:id/whitelist', optionalAuth, async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!server.rconPassword) {
|
||||
return res.json({ players: [], cached: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendRconCommand(server, 'whitelist list');
|
||||
const match = response.trim().match(/:\s*(.+)$/);
|
||||
let players = [];
|
||||
if (match && match[1]) {
|
||||
players = match[1].split(',').map(p => p.trim()).filter(p => p.length > 0);
|
||||
}
|
||||
setCachedWhitelist(server.id, players);
|
||||
res.json({ players, cached: false });
|
||||
} catch (err) {
|
||||
const players = getCachedWhitelist(server.id);
|
||||
res.json({ players, cached: true });
|
||||
}
|
||||
});
|
||||
|
||||
// RCON command (moderator+)
|
||||
router.post('/:id/rcon', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
if (!server.rconPassword) {
|
||||
return res.status(400).json({ error: 'RCON not configured for this server' });
|
||||
}
|
||||
|
||||
const { command } = req.body;
|
||||
if (!command) {
|
||||
return res.status(400).json({ error: 'Command required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendRconCommand(server, command);
|
||||
logActivity(req.user.id, req.user.username, 'rcon_command', server.id, command, req.user.discordId, req.user.avatar);
|
||||
if (command.startsWith("whitelist ")) {
|
||||
try {
|
||||
const listResponse = await sendRconCommand(server, "whitelist list");
|
||||
const match = listResponse.trim().match(/:\s*(.+)$/);
|
||||
let players = [];
|
||||
if (match && match[1]) {
|
||||
players = match[1].split(",").map(p => p.trim()).filter(p => p.length > 0);
|
||||
}
|
||||
setCachedWhitelist(server.id, players);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
res.json({ response });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Initialize auto-shutdown settings table
|
||||
initAutoShutdownSettings();
|
||||
|
||||
// ============ AUTO-SHUTDOWN ROUTES ============
|
||||
|
||||
// Get auto-shutdown settings for a server
|
||||
router.get('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const settings = getAutoShutdownSettings(req.params.id);
|
||||
const emptySince = getEmptySince(req.params.id);
|
||||
|
||||
res.json({
|
||||
enabled: settings?.enabled === 1 || false,
|
||||
timeoutMinutes: settings?.timeout_minutes || 15,
|
||||
emptySinceMinutes: emptySince
|
||||
});
|
||||
});
|
||||
|
||||
// Update auto-shutdown settings for a server
|
||||
router.put('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
|
||||
const config = loadConfig();
|
||||
const server = config.servers.find(s => s.id === req.params.id);
|
||||
if (!server) return res.status(404).json({ error: 'Server not found' });
|
||||
|
||||
const { enabled, timeoutMinutes } = req.body;
|
||||
const timeout = Math.max(1, Math.min(1440, timeoutMinutes || 15));
|
||||
|
||||
setAutoShutdownSettings(req.params.id, enabled, timeout);
|
||||
logActivity(req.user.id, req.user.username, 'autoshutdown_config', req.params.id, 'Enabled: ' + enabled + ', Timeout: ' + timeout + ' min', req.user.discordId, req.user.avatar);
|
||||
console.log('[AutoShutdown] Settings updated for ' + req.params.id + ': enabled=' + enabled + ', timeout=' + timeout + 'min');
|
||||
|
||||
res.json({ message: 'Auto-shutdown settings updated', enabled, timeoutMinutes: timeout });
|
||||
});
|
||||
|
||||
|
||||
// Get display settings for a specific server
|
||||
router.get("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
|
||||
try {
|
||||
const settings = getServerDisplaySettings(req.params.id);
|
||||
res.json(settings || { server_id: req.params.id, address: "", hint: "" });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Update display settings for a server (superadmin only)
|
||||
router.put("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
|
||||
const { address, hint } = req.body;
|
||||
try {
|
||||
setServerDisplaySettings(req.params.id, address || "", hint || "");
|
||||
res.json({ message: "Display settings updated", address, hint });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
export default router;
|
||||
159
gsm-backend/services/rcon.js
Normal file
159
gsm-backend/services/rcon.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Rcon } from 'rcon-client';
|
||||
|
||||
const rconConnections = new Map();
|
||||
const playerCache = new Map();
|
||||
const CACHE_TTL = 30000; // 30 seconds
|
||||
|
||||
async function getConnection(server) {
|
||||
const key = `${server.host}:${server.rconPort}`;
|
||||
|
||||
if (rconConnections.has(key)) {
|
||||
const conn = rconConnections.get(key);
|
||||
if (conn.authenticated) {
|
||||
return conn;
|
||||
}
|
||||
rconConnections.delete(key);
|
||||
}
|
||||
|
||||
const rcon = await Rcon.connect({
|
||||
host: server.host,
|
||||
port: server.rconPort,
|
||||
password: server.rconPassword,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
rcon.on('error', (err) => {
|
||||
console.error(`RCON error for ${key}:`, err.message);
|
||||
rconConnections.delete(key);
|
||||
});
|
||||
|
||||
rcon.on('end', () => {
|
||||
rconConnections.delete(key);
|
||||
});
|
||||
|
||||
// Handle socket errors to prevent crash
|
||||
if (rcon.socket) {
|
||||
rcon.socket.on('error', (err) => {
|
||||
console.error(`RCON socket error for ${key}:`, err.message);
|
||||
rconConnections.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
rconConnections.set(key, rcon);
|
||||
return rcon;
|
||||
}
|
||||
|
||||
export async function sendRconCommand(server, command) {
|
||||
if (!server.rconPassword) {
|
||||
throw new Error('RCON password not configured');
|
||||
}
|
||||
|
||||
const rcon = await getConnection(server);
|
||||
return await rcon.send(command);
|
||||
}
|
||||
|
||||
export async function getPlayers(server) {
|
||||
const cacheKey = `${server.id}-count`;
|
||||
const cached = playerCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.time < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
let result = { online: 0, max: null };
|
||||
|
||||
if (server.type === 'minecraft') {
|
||||
const response = await sendRconCommand(server, 'list');
|
||||
const match = response.match(/There are (\d+) of a max of (\d+) players online/);
|
||||
if (match) {
|
||||
result = { online: parseInt(match[1]), max: parseInt(match[2]) };
|
||||
}
|
||||
} else if (server.type === 'factorio') {
|
||||
const response = await sendRconCommand(server, '/players online count');
|
||||
const match = response.match(/(\d+)/);
|
||||
if (match) {
|
||||
result = { online: parseInt(match[1]), max: null };
|
||||
}
|
||||
} else if (server.type === 'zomboid') {
|
||||
const response = await sendRconCommand(server, 'players');
|
||||
// Format: "Players connected (X):" or "Players connected (X): Player1, Player2"
|
||||
const match = response.match(/Players connected \((\d+)\)/);
|
||||
if (match) {
|
||||
result = { online: parseInt(match[1]), max: null };
|
||||
}
|
||||
} else if (server.type === 'vrising') {
|
||||
const response = await sendRconCommand(server, 'listusers');
|
||||
// Count lines that contain player info
|
||||
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
|
||||
result = { online: lines.length, max: null };
|
||||
}
|
||||
|
||||
playerCache.set(cacheKey, { data: result, time: Date.now() });
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get players for ${server.id}:`, err.message);
|
||||
// Clear cache on error - server might be offline
|
||||
playerCache.delete(cacheKey);
|
||||
return { online: 0, max: null };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlayerList(server) {
|
||||
const cacheKey = `${server.id}-list`;
|
||||
const cached = playerCache.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.time < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
try {
|
||||
let players = [];
|
||||
|
||||
if (server.type === 'minecraft') {
|
||||
const response = await sendRconCommand(server, 'list');
|
||||
// Format: "There are X of a max of Y players online: Player1, Player2, Player3"
|
||||
const colonIndex = response.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const playerPart = response.substring(colonIndex + 1).trim();
|
||||
if (playerPart) {
|
||||
players = playerPart.split(',').map(p => p.trim()).filter(p => p);
|
||||
}
|
||||
}
|
||||
} else if (server.type === 'factorio') {
|
||||
const response = await sendRconCommand(server, '/players online');
|
||||
// Format: "Online players (X):\n player1 (online)\n player2 (online)"
|
||||
const lines = response.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*(\S+)\s*\(online\)/);
|
||||
if (match) {
|
||||
players.push(match[1]);
|
||||
}
|
||||
}
|
||||
} else if (server.type === 'zomboid') {
|
||||
const response = await sendRconCommand(server, 'players');
|
||||
// Format: "Players connected (X): \n-Player1\n-Player2\n-Player3"
|
||||
const lines = response.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('-')) {
|
||||
players.push(trimmed.substring(1));
|
||||
}
|
||||
}
|
||||
} else if (server.type === 'vrising') {
|
||||
const response = await sendRconCommand(server, 'listusers');
|
||||
// Parse player names from listusers output
|
||||
const lines = response.split('\n').filter(l => l.trim() && !l.includes('listusers'));
|
||||
players = lines.map(l => l.trim()).filter(p => p);
|
||||
}
|
||||
|
||||
const result = { players };
|
||||
playerCache.set(cacheKey, { data: result, time: Date.now() });
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`Failed to get player list for ${server.id}:`, err.message);
|
||||
// Clear cache on error - server might be offline
|
||||
playerCache.delete(cacheKey);
|
||||
return { players: [] };
|
||||
}
|
||||
}
|
||||
493
gsm-backend/services/ssh.js
Normal file
493
gsm-backend/services/ssh.js
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