Cleanup repo, add Gitea CI/CD workflow, improve error handling
All checks were successful
Deploy GSM / deploy (push) Successful in 1m25s

- Remove temp files and reorganize docs
- Add .gitea/workflows/deploy.yml for automated deployment
- Add unreachable host checks to server routes (/:id, logs, start/stop/restart)
- Add unreachable checks to config routes (zomboid, terraria, openttd)
- Return HTTP 503 with unreachable flag instead of crashing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Zielonka
2026-01-09 12:15:32 +01:00
parent f2f9e02fb2
commit 2d9a5910fa
22 changed files with 181 additions and 2861 deletions

View File

@@ -0,0 +1,63 @@
name: Deploy GSM
on:
push:
branches: [main]
paths:
- 'gsm-backend/**'
- 'gsm-frontend/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install backend dependencies
run: |
cd gsm-backend
npm ci --production
- name: Install frontend dependencies and build
run: |
cd gsm-frontend
npm ci
npm run build
- name: Deploy Backend
uses: appleboy/scp-action@v0.1.7
with:
host: 192.168.2.30
username: root
key: ${{ secrets.SSH_DEPLOY_KEY }}
source: "gsm-backend/"
target: "/opt/gameserver-monitor/"
strip_components: 1
- name: Deploy Frontend
uses: appleboy/scp-action@v0.1.7
with:
host: 192.168.2.30
username: root
key: ${{ secrets.SSH_DEPLOY_KEY }}
source: "gsm-frontend/dist/"
target: "/opt/gameserver-monitor/frontend/"
strip_components: 2
- name: Restart Services
uses: appleboy/ssh-action@v1.0.3
with:
host: 192.168.2.30
username: root
key: ${{ secrets.SSH_DEPLOY_KEY }}
script: |
cd /opt/gameserver-monitor
pm2 restart gsm-backend || pm2 start backend/server.js --name gsm-backend
echo "Deploy complete!"

View File

@@ -1,172 +0,0 @@
import { useState, useEffect } from 'react'
import { useUser } from '../context/UserContext'
import { getActivityLog } from '../api'
const actionLabels = {
server_start: 'Server gestartet',
server_stop: 'Server gestoppt',
server_restart: 'Server neugestartet',
rcon_command: 'RCON Befehl',
autoshutdown_config: 'Auto-Shutdown geändert',
zomboid_config: 'Config geändert',
factorio_world_create: 'Welt erstellt',
factorio_world_delete: 'Welt gelöscht'
}
const actionIcons = {
server_start: '▶️',
server_stop: '⏹️',
server_restart: '🔄',
rcon_command: '💻',
autoshutdown_config: '⏱️',
zomboid_config: '📝',
factorio_world_create: '🌍',
factorio_world_delete: '🗑️'
}
const serverLabels = {
minecraft: 'Minecraft',
factorio: 'Factorio',
zomboid: 'Project Zomboid',
vrising: 'V Rising'
}
function getAvatarUrl(discordId, avatar) {
if (!discordId || !avatar) return null
return `https://cdn.discordapp.com/avatars/${discordId}/${avatar}.png?size=32`
}
function getDiscordProfileUrl(discordId) {
return `https://discord.com/users/${discordId}`
}
export default function ActivityLog({ onClose }) {
const { token } = useUser()
const [logs, setLogs] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const fetchLogs = async () => {
try {
const data = await getActivityLog(token, 100)
setLogs(data)
setError('')
} catch (err) {
setError('Fehler beim Laden des Activity Logs')
} finally {
setLoading(false)
}
}
fetchLogs()
}, [token])
const formatDate = (dateStr) => {
const date = new Date(dateStr + 'Z')
const now = new Date()
const diff = now - date
if (diff < 60000) return 'Gerade eben'
if (diff < 3600000) return Math.floor(diff / 60000) + ' Min'
if (diff < 86400000) return Math.floor(diff / 3600000) + ' Std'
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
return (
<div className="modal-backdrop fade-in" onClick={onClose}>
<div className="modal fade-in-scale" style={{ maxWidth: '42rem' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Activity Log</h2>
<button onClick={onClose} className="btn btn-ghost">
Schließen
</button>
</div>
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{error && (
<div className="alert alert-error mb-4">{error}</div>
)}
{loading ? (
<div className="text-center py-4 text-neutral-400">Laden...</div>
) : logs.length === 0 ? (
<div className="text-center py-4 text-neutral-500">Noch keine Aktivitäten</div>
) : (
<div className="space-y-2">
{logs.map((log) => {
const avatarUrl = getAvatarUrl(log.discord_id, log.avatar)
const profileUrl = log.discord_id ? getDiscordProfileUrl(log.discord_id) : null
return (
<div key={log.id} className="card p-3 flex items-start gap-3">
{/* Avatar or Action Icon */}
{avatarUrl ? (
<a
href={profileUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0"
>
<img
src={avatarUrl}
alt=""
className="w-8 h-8 rounded-full hover:ring-2 hover:ring-blue-500 transition-all"
/>
</a>
) : (
<div className="w-8 h-8 rounded-full bg-neutral-700 flex items-center justify-center flex-shrink-0">
<span className="text-neutral-400 text-xs">
{log.username?.charAt(0)?.toUpperCase()}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{profileUrl ? (
<a
href={profileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-white font-medium hover:text-blue-400 transition-colors"
>
{log.username}
</a>
) : (
<span className="text-white font-medium">{log.username}</span>
)}
<span className="text-neutral-500">
{actionIcons[log.action] || '📋'} {actionLabels[log.action] || log.action}
</span>
{log.target && (
<span className="text-blue-400">
{serverLabels[log.target] || log.target}
</span>
)}
</div>
{log.details && (
<div className="text-sm text-neutral-500 mt-1 truncate font-mono">
{log.details}
</div>
)}
</div>
<div className="text-xs text-neutral-500 whitespace-nowrap">
{formatDate(log.created_at)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
}

156
App.jsx
View File

@@ -1,156 +0,0 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useSearchParams, useNavigate } from 'react-router-dom'
import { UserProvider } from './context/UserContext'
import Dashboard from './pages/Dashboard'
import ServerDetail from './pages/ServerDetail'
// OAuth Callback Handler
function AuthCallback({ onLogin }) {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [error, setError] = useState(null)
useEffect(() => {
const token = searchParams.get('token')
const errorParam = searchParams.get('error')
if (errorParam) {
const errorMessages = {
'discord_denied': 'Discord-Anmeldung abgebrochen',
'no_code': 'Kein Autorisierungscode erhalten',
'not_in_guild': 'Du bist nicht Mitglied des Discord-Servers',
'oauth_failed': 'Anmeldung fehlgeschlagen'
}
setError(errorMessages[errorParam] || 'Unbekannter Fehler')
setTimeout(() => navigate('/'), 3000)
return
}
if (token) {
onLogin(token)
navigate('/')
} else {
navigate('/')
}
}, [searchParams, onLogin, navigate])
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950">
<div className="text-center">
<div className="text-red-400 text-xl mb-2">{error}</div>
<div className="text-neutral-500">Weiterleitung...</div>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950">
<div className="text-neutral-400">Anmeldung wird verarbeitet...</div>
</div>
)
}
// Login page for unauthenticated users
function LoginPage() {
const [searchParams] = useSearchParams()
const error = searchParams.get('error')
const errorMessages = {
'discord_denied': 'Discord-Anmeldung abgebrochen',
'no_code': 'Kein Autorisierungscode erhalten',
'not_in_guild': 'Du bist nicht Mitglied des Discord-Servers',
'oauth_failed': 'Anmeldung fehlgeschlagen'
}
const handleDiscordLogin = () => {
window.location.href = '/api/auth/discord'
}
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950">
<div className="text-center">
<img src="/navbarlogoweiß.png" alt="Logo" className="h-16 mx-auto mb-6" />
<h1 className="text-2xl font-bold text-white mb-2">Gameserver Management</h1>
<p className="text-neutral-400 mb-8">Melde dich mit Discord an, um fortzufahren</p>
{error && (
<div className="mb-6 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
{errorMessages[error] || 'Anmeldung fehlgeschlagen'}
</div>
)}
<button
onClick={handleDiscordLogin}
className="flex items-center gap-3 mx-auto px-6 py-3 bg-[#5865F2] hover:bg-[#4752C4] text-white font-medium rounded-lg transition-colors"
>
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Mit Discord anmelden
</button>
<p className="mt-8 text-sm text-neutral-600">
Nur für Mitglieder des Discord-Servers
</p>
</div>
</div>
)
}
export default function App() {
const [token, setToken] = useState(localStorage.getItem('gsm_token'))
const handleLogin = (newToken) => {
localStorage.setItem('gsm_token', newToken)
setToken(newToken)
}
const handleLogout = () => {
localStorage.removeItem('gsm_token')
setToken(null)
}
return (
<UserProvider token={token}>
<BrowserRouter>
<Routes>
<Route
path="/"
element={
token ? (
<Dashboard onLogout={handleLogout} />
) : (
<LoginPage />
)
}
/>
<Route
path="/server/:serverId"
element={
token ? (
<ServerDetail onLogout={handleLogout} />
) : (
<Navigate to="/" replace />
)
}
/>
<Route
path="/auth/callback"
element={<AuthCallback onLogin={handleLogin} />}
/>
<Route
path="/auth/discord/callback"
element={<AuthCallback onLogin={handleLogin} />}
/>
<Route
path="/login"
element={<LoginPage />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</UserProvider>
)
}

View File

@@ -17,6 +17,7 @@ The homelab consists of:
- **V Rising Server (192.168.2.52)**: Dedicated server (LXC) - **V Rising Server (192.168.2.52)**: Dedicated server (LXC)
- **Palworld Server (192.168.2.53)**: Dedicated server with systemd (LXC) - **Palworld Server (192.168.2.53)**: Dedicated server with systemd (LXC)
- **Project Zomboid Server (10.0.30.66)**: Dedicated server (external VM) - **Project Zomboid Server (10.0.30.66)**: Dedicated server (external VM)
- **Terraria Server (10.0.30.202)**: Vanilla server mit PM2 (external VM, VPN)
## Key Technical Details ## Key Technical Details

View File

@@ -1,164 +0,0 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { getServers } from '../api'
import { useUser } from '../context/UserContext'
import ServerCard from '../components/ServerCard'
import UserManagement from '../components/UserManagement'
import ActivityLog from '../components/ActivityLog'
export default function Dashboard({ onLogout }) {
const navigate = useNavigate()
const { user, token, loading: userLoading, isSuperadmin, role, avatarUrl } = useUser()
const [servers, setServers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showUserMgmt, setShowUserMgmt] = useState(false)
const [showActivityLog, setShowActivityLog] = useState(false)
const fetchServers = async () => {
try {
const data = await getServers(token)
setServers(data)
setError('')
} catch (err) {
if (err.message.includes('401') || err.message.includes('403')) {
onLogout()
} else {
setError('Verbindung zum Server fehlgeschlagen')
}
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!userLoading && token) {
fetchServers()
const interval = setInterval(fetchServers, 10000)
return () => clearInterval(interval)
}
}, [token, userLoading])
const roleLabels = {
user: 'Viewer',
moderator: 'Operator',
superadmin: 'Admin'
}
if (userLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-neutral-400">Laden...</div>
</div>
)
}
const onlineCount = servers.filter(s => s.running).length
document.title = 'Dashboard | Zeasy GSM'
const totalPlayers = servers.reduce((sum, s) => sum + (s.players?.online || 0), 0)
return (
<div className="min-h-screen page-enter">
{/* Header */}
<header className="border-b border-neutral-800 bg-neutral-900/50 backdrop-blur-sm sticky top-0 z-10">
<div className="container-main py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<a href="https://zeasy.software" target="_blank" rel="noopener noreferrer" className="relative block group">
<img src="/navbarlogoweiß.png" alt="Logo" className="h-8 transition-opacity duration-300 group-hover:opacity-0" />
<img src="/navbarlogograuer.png" alt="Logo" className="h-8 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
</a>
<span className="text-xl font-semibold text-white hidden sm:inline">Gameserver Management</span>
</div>
<div className="hidden md:flex items-center gap-4 text-sm text-neutral-400">
<span>
<span className="text-white font-medium">{onlineCount}</span>/{servers.length} online
</span>
<span className="text-neutral-600">|</span>
<span>
<span className="text-white font-medium">{totalPlayers}</span> Spieler
</span>
</div>
<div className="flex items-center gap-3">
{/* User info with Discord avatar */}
<div className="flex items-center gap-3">
{avatarUrl && (
<img
src={avatarUrl}
alt="Avatar"
className="w-8 h-8 rounded-full"
/>
)}
<div className="hidden sm:block text-right">
<div className="text-sm text-white">{user?.username}</div>
<div className="text-xs text-neutral-500">{roleLabels[role]}</div>
</div>
</div>
{isSuperadmin && (
<>
<button
onClick={() => setShowActivityLog(true)}
className="btn btn-ghost"
>
Log
</button>
<button
onClick={() => setShowUserMgmt(true)}
className="btn btn-ghost"
>
Benutzer
</button>
</>
)}
<button
onClick={onLogout}
className="btn btn-outline"
>
Abmelden
</button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="container-main py-8">
{error && (
<div className="mb-6 alert alert-error fade-in">
{error}
</div>
)}
{loading ? (
<div className="text-center py-12">
<div className="text-neutral-400">Server werden geladen...</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{servers.map((server, index) => (
<div
key={server.id}
className="fade-in-up"
style={{ animationDelay: index * 50 + 'ms', animationFillMode: 'both' }}
>
<ServerCard
server={server}
onClick={() => navigate('/server/' + server.id)}
isAuthenticated={true}
/>
</div>
))}
</div>
)}
</main>
{/* Modals */}
{showUserMgmt && (
<UserManagement onClose={() => setShowUserMgmt(false)} />
)}
{showActivityLog && (
<ActivityLog onClose={() => setShowActivityLog(false)} />
)}
</div>
)
}

View File

@@ -1,61 +0,0 @@
import { createContext, useContext, useState, useEffect } from 'react'
import { getMe } from '../api'
const UserContext = createContext(null)
export function UserProvider({ children, token }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!token) {
setUser(null)
setLoading(false)
return
}
getMe(token)
.then(data => {
setUser(data)
setLoading(false)
})
.catch(() => {
// Token invalid, clear it
localStorage.removeItem('gsm_token')
setUser(null)
setLoading(false)
})
}, [token])
// Get Discord avatar URL
const getAvatarUrl = () => {
if (!user?.discordId || !user?.avatar) {
return null
}
return `https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`
}
const value = {
user,
token,
loading,
role: user?.role || 'user',
isModerator: ['moderator', 'superadmin'].includes(user?.role),
isSuperadmin: user?.role === 'superadmin',
avatarUrl: getAvatarUrl()
}
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
)
}
export function useUser() {
const context = useContext(UserContext)
if (!context) {
throw new Error('useUser must be used within UserProvider')
}
return context
}

View File

@@ -1,102 +0,0 @@
import { useState, useEffect } from 'react'
import { useUser } from '../context/UserContext'
import { getUsers } from '../api'
export default function UserManagement({ onClose }) {
const { token } = useUser()
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
const fetchUsers = async () => {
try {
const data = await getUsers(token)
setUsers(data)
setError('')
} catch (err) {
setError('Fehler beim Laden der Benutzer')
} finally {
setLoading(false)
}
}
fetchUsers()
}, [token])
const roleLabels = {
user: 'Viewer',
moderator: 'Operator',
superadmin: 'Admin'
}
const roleColors = {
user: 'text-neutral-400',
moderator: 'text-blue-400',
superadmin: 'text-yellow-400'
}
const getAvatarUrl = (user) => {
if (!user.discord_id || !user.avatar) return null
return `https://cdn.discordapp.com/avatars/${user.discord_id}/${user.avatar}.png?size=64`
}
return (
<div className="modal-backdrop fade-in" onClick={onClose}>
<div className="modal fade-in-scale" style={{ maxWidth: '32rem' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">Benutzerliste</h2>
<button onClick={onClose} className="btn btn-ghost">
Schließen
</button>
</div>
<div className="modal-body">
{error && (
<div className="alert alert-error mb-4">{error}</div>
)}
<p className="text-sm text-neutral-500 mb-4">
Benutzer, die sich über Discord angemeldet haben. Rollen werden durch Discord-Rollen bestimmt.
</p>
{loading ? (
<div className="text-center py-4 text-neutral-400">Laden...</div>
) : users.length === 0 ? (
<div className="text-center py-4 text-neutral-500">Noch keine Benutzer angemeldet</div>
) : (
<div className="space-y-2">
{users.map((user) => (
<div key={user.id} className="card p-3 flex items-center gap-3">
{getAvatarUrl(user) ? (
<img
src={getAvatarUrl(user)}
alt=""
className="w-10 h-10 rounded-full"
/>
) : (
<div className="w-10 h-10 rounded-full bg-neutral-700 flex items-center justify-center">
<span className="text-neutral-400 text-sm">
{user.username?.charAt(0)?.toUpperCase()}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<div className="text-white font-medium truncate">{user.username}</div>
<div className="text-xs text-neutral-500 truncate">
{user.discord_id}
</div>
</div>
<span className={`text-sm font-medium ${roleColors[user.role]}`}>
{roleLabels[user.role]}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,10 +0,0 @@
[Interface]
PrivateKey = WC2HEXQ10sm5sE9Apj5wBBv2/CSRmpQNUovT1xSO8kM=
Address = 10.0.200.201/32
DNS = 10.0.0.1
[Peer]
PublicKey = wgP8/WMTzhr45bH2GFabcYUhycCLF/pPczghWgLJN0Q=
Endpoint = beeck.zeasy.dev:47199
AllowedIPs = 10.0.0.0/16
PersistentKeepalive = 60

View File

@@ -1,326 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { FileText, Save, RefreshCw, AlertTriangle, Check, X, ChevronDown } from 'lucide-react'
import { getZomboidConfigs, getZomboidConfig, saveZomboidConfig } from '../api'
export default function ZomboidConfigEditor({ token }) {
const [files, setFiles] = useState([])
const [selectedFile, setSelectedFile] = useState(null)
const [content, setContent] = useState('')
const [originalContent, setOriginalContent] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const [hasChanges, setHasChanges] = useState(false)
const textareaRef = useRef(null)
const highlightRef = useRef(null)
// Load file list
useEffect(() => {
loadFiles()
}, [token])
// Track changes
useEffect(() => {
setHasChanges(content !== originalContent)
}, [content, originalContent])
// Sync scroll between textarea and highlight div
const handleScroll = () => {
if (highlightRef.current && textareaRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft
}
}
async function loadFiles() {
setLoading(true)
setError(null)
try {
const data = await getZomboidConfigs(token)
setFiles(data.files || [])
if (data.files?.length > 0 && !selectedFile) {
loadFile(data.files[0].filename)
}
} catch (err) {
setError('Fehler beim Laden der Config-Dateien: ' + err.message)
} finally {
setLoading(false)
}
}
async function loadFile(filename) {
setLoading(true)
setError(null)
setSuccess(null)
try {
const data = await getZomboidConfig(token, filename)
setSelectedFile(filename)
setContent(data.content)
setOriginalContent(data.content)
} catch (err) {
setError('Fehler beim Laden: ' + err.message)
} finally {
setLoading(false)
}
}
async function handleSave() {
if (!selectedFile || !hasChanges) return
setSaving(true)
setError(null)
setSuccess(null)
try {
await saveZomboidConfig(token, selectedFile, content)
setOriginalContent(content)
setSuccess('Config gespeichert! Server-Neustart erforderlich für Änderungen.')
setTimeout(() => setSuccess(null), 5000)
} catch (err) {
setError('Fehler beim Speichern: ' + err.message)
} finally {
setSaving(false)
}
}
function handleDiscard() {
setContent(originalContent)
setError(null)
setSuccess(null)
}
function getFileDescription(filename) {
const descriptions = {
'Project.ini': 'Server-Einstellungen (PVP, Spieler, Netzwerk)',
'Project_SandboxVars.lua': 'Gameplay-Einstellungen (Zombies, Loot, Schwierigkeit)',
'Project_spawnpoints.lua': 'Spawn-Punkte für neue Spieler',
'Project_spawnregions.lua': 'Spawn-Regionen Konfiguration'
}
return descriptions[filename] || filename
}
// Highlight syntax based on file type
function highlightSyntax(text, filename) {
if (!text) return ''
const isLua = filename?.endsWith('.lua')
const lines = text.split('\n')
return lines.map((line, i) => {
let highlighted = line
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
if (isLua) {
// Lua: -- comments
if (line.trim().startsWith('--')) {
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
} else if (line.includes('--')) {
const idx = line.indexOf('--')
const code = highlighted.substring(0, idx)
const comment = highlighted.substring(idx)
highlighted = `${code}<span class="text-emerald-500">${comment}</span>`
}
// Highlight true/false/nil
highlighted = highlighted
.replace(/\b(true|false|nil)\b/g, '<span class="text-orange-400">$1</span>')
// Highlight numbers
highlighted = highlighted
.replace(/\b(\d+\.?\d*)\b/g, '<span class="text-cyan-400">$1</span>')
} else {
// INI: # comments
if (line.trim().startsWith('#')) {
highlighted = `<span class="text-emerald-500">${highlighted}</span>`
}
// Highlight key=value
else if (line.includes('=')) {
const idx = line.indexOf('=')
const key = highlighted.substring(0, idx)
const value = highlighted.substring(idx + 1)
highlighted = `<span class="text-blue-400">${key}</span>=<span class="text-amber-300">${value}</span>`
}
}
return highlighted
}).join('\n')
}
if (loading && files.length === 0) {
return (
<div className="flex items-center justify-center p-8">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
<span className="ml-2 text-gray-400">Lade Config-Dateien...</span>
</div>
)
}
return (
<div className="space-y-4">
{/* File selector */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<select
value={selectedFile || ''}
onChange={(e) => {
if (hasChanges) {
if (!confirm('Ungespeicherte Änderungen verwerfen?')) return
}
loadFile(e.target.value)
}}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 pr-10 text-white appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{files.map(file => (
<option key={file.filename} value={file.filename}>
{file.filename}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
<button
onClick={() => loadFile(selectedFile)}
disabled={loading}
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
title="Neu laden"
>
<RefreshCw className={"w-5 h-5 " + (loading ? 'animate-spin' : '')} />
</button>
</div>
{/* File description */}
{selectedFile && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<FileText className="w-4 h-4" />
<span>{getFileDescription(selectedFile)}</span>
</div>
)}
{/* Error/Success messages */}
{error && (
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400">
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg text-green-400">
<Check className="w-5 h-5 flex-shrink-0" />
<span>{success}</span>
</div>
)}
{/* Editor with syntax highlighting */}
<div className="relative h-[500px] rounded-lg overflow-hidden border border-gray-700">
{/* Highlighted background layer */}
<pre
ref={highlightRef}
className="absolute inset-0 p-4 m-0 text-sm font-mono bg-gray-900 text-gray-100 overflow-auto pointer-events-none whitespace-pre-wrap break-words"
style={{ wordBreak: 'break-word' }}
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: highlightSyntax(content, selectedFile) + '\n' }}
/>
{/* Transparent textarea for editing */}
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onScroll={handleScroll}
className="absolute inset-0 w-full h-full p-4 text-sm font-mono bg-transparent text-transparent caret-white resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
spellCheck={false}
disabled={loading}
style={{ caretColor: 'white' }}
/>
{/* Change indicator */}
{hasChanges && (
<div className="absolute top-2 right-2 px-2 py-1 bg-yellow-500/20 text-yellow-400 text-xs rounded z-10">
Ungespeichert
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500">
{selectedFile && files.find(f => f.filename === selectedFile)?.modified && (
<span>Zuletzt geändert: {files.find(f => f.filename === selectedFile).modified}</span>
)}
</div>
<div className="flex items-center gap-3">
{hasChanges && (
<button
onClick={handleDiscard}
className="flex items-center gap-2 px-4 py-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
Verwerfen
</button>
)}
<button
onClick={handleSave}
disabled={!hasChanges || saving}
className={"flex items-center gap-2 px-4 py-2 rounded-lg transition-colors " +
(hasChanges && !saving
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-gray-700 text-gray-500 cursor-not-allowed'
)}
>
{saving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Speichern...
</>
) : (
<>
<Save className="w-4 h-4" />
Speichern
</>
)}
</button>
</div>
</div>
{/* Legend */}
<div className="mt-4 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
<h4 className="text-sm font-medium text-gray-300 mb-2">Legende</h4>
<div className="flex flex-wrap gap-4 text-xs">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-emerald-500"></span>
<span className="text-gray-400">Kommentare</span>
</div>
{selectedFile?.endsWith('.ini') && (
<>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-blue-400"></span>
<span className="text-gray-400">Einstellung</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-amber-300"></span>
<span className="text-gray-400">Wert</span>
</div>
</>
)}
{selectedFile?.endsWith('.lua') && (
<>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-orange-400"></span>
<span className="text-gray-400">Boolean/Nil</span>
</div>
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-cyan-400"></span>
<span className="text-gray-400">Zahlen</span>
</div>
</>
)}
</div>
<p className="text-xs text-gray-500 mt-3">Änderungen werden erst nach Server-Neustart aktiv. Ein Backup wird automatisch erstellt.</p>
</div>
</div>
)
}

244
auth.js
View File

@@ -1,244 +0,0 @@
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { db, VALID_ROLES, initDiscordUsers } from '../db/init.js';
import { authenticateToken, requireRole } from '../middleware/auth.js';
import { getDiscordAuthUrl, exchangeCode, getDiscordUser, getGuildMember, getGuildMemberships, getUserRole, getUserRoleFromMemberships } from '../services/discord.js';
const router = Router();
// Initialize Discord users table
initDiscordUsers();
// ===== Guest Login =====
// Create guest token (view-only, expires in 24h)
router.post('/guest', (req, res) => {
const guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
const token = jwt.sign(
{
id: guestId,
username: 'Gast',
role: 'guest',
isGuest: true
},
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token });
});
// ===== Discord OAuth2 =====
// Start Discord OAuth2 flow
router.get('/discord', (req, res) => {
res.redirect(getDiscordAuthUrl());
});
// Discord OAuth2 callback
router.get('/discord/callback', async (req, res) => {
const { code, error } = req.query;
// Redirect URL for frontend
const frontendUrl = process.env.FRONTEND_URL || 'https://gsm.dimension47.de';
if (error) {
return res.redirect(`${frontendUrl}/login?error=discord_denied`);
}
if (!code) {
return res.redirect(`${frontendUrl}/login?error=no_code`);
}
try {
// Exchange code for access token
const tokenData = await exchangeCode(code);
// Get Discord user info
const discordUser = await getDiscordUser(tokenData.access_token);
// Check if user is in any of the configured guilds
const memberships = await getGuildMemberships(discordUser.id);
if (!memberships) {
return res.redirect(`${frontendUrl}/login?error=not_in_guild`);
}
// Determine role based on Discord roles (highest role from all servers)
const role = getUserRoleFromMemberships(memberships);
// Use first membership for display name
const member = memberships[0].member;
// Get display name (nickname or username)
const displayName = member.nick || discordUser.global_name || discordUser.username;
// Upsert user in database
const existingUser = db.prepare('SELECT * FROM discord_users WHERE discord_id = ?').get(discordUser.id);
let userId;
if (existingUser) {
// Update existing user
db.prepare(`
UPDATE discord_users
SET username = ?, discriminator = ?, avatar = ?, role = ?, updated_at = CURRENT_TIMESTAMP
WHERE discord_id = ?
`).run(displayName, discordUser.discriminator || '0', discordUser.avatar, role, discordUser.id);
userId = existingUser.id;
} else {
// Create new user
const result = db.prepare(`
INSERT INTO discord_users (discord_id, username, discriminator, avatar, role)
VALUES (?, ?, ?, ?, ?)
`).run(discordUser.id, displayName, discordUser.discriminator || '0', discordUser.avatar, role);
userId = result.lastInsertRowid;
}
// Create JWT token
const token = jwt.sign(
{
id: userId,
discordId: discordUser.id,
username: displayName,
role,
avatar: discordUser.avatar
},
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
// Redirect to frontend with token
res.redirect(`${frontendUrl}/auth/callback?token=${token}`);
} catch (err) {
console.error('Discord OAuth error:', err);
res.redirect(`${frontendUrl}/login?error=oauth_failed`);
}
});
// Get current user info
router.get('/me', authenticateToken, (req, res) => {
// Check if it's a guest user
if (req.user.isGuest) {
return res.json({
id: req.user.id,
username: req.user.username,
role: req.user.role,
isGuest: true
});
}
// Check if it's a Discord user
if (req.user.discordId) {
const user = db.prepare('SELECT id, discord_id, username, avatar, role FROM discord_users WHERE id = ?').get(req.user.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
return res.json({
id: user.id,
discordId: user.discord_id,
username: user.username,
avatar: user.avatar,
role: user.role
});
}
// Fallback for old users (shouldn't happen after migration)
const user = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.user.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ id: user.id, username: user.username, role: user.role });
});
// Refresh user role from Discord (useful if roles changed)
router.post('/refresh-role', authenticateToken, async (req, res) => {
if (!req.user.discordId) {
return res.status(400).json({ error: 'Not a Discord user' });
}
try {
const memberships = await getGuildMemberships(req.user.discordId);
if (!memberships) {
return res.status(403).json({ error: 'No longer in any guild' });
}
const newRole = getUserRoleFromMemberships(memberships);
db.prepare('UPDATE discord_users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE discord_id = ?')
.run(newRole, req.user.discordId);
// Generate new token with updated role
const token = jwt.sign(
{
id: req.user.id,
discordId: req.user.discordId,
username: req.user.username,
role: newRole,
avatar: req.user.avatar
},
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({ token, role: newRole });
} catch (err) {
console.error('Failed to refresh role:', err);
res.status(500).json({ error: 'Failed to refresh role' });
}
});
// ===== User Management (superadmin only) =====
// Get all Discord users
router.get('/users', authenticateToken, requireRole('superadmin'), (req, res) => {
const users = db.prepare(`
SELECT id, discord_id, username, avatar, role, created_at, updated_at
FROM discord_users
ORDER BY created_at DESC
`).all();
res.json(users);
});
// Update user role (override Discord role)
router.patch('/users/:id/role', authenticateToken, requireRole('superadmin'), (req, res) => {
const userId = parseInt(req.params.id);
const { role } = req.body;
if (!VALID_ROLES.includes(role)) {
return res.status(400).json({ error: 'Invalid role' });
}
if (userId === req.user.id) {
return res.status(400).json({ error: 'Cannot change your own role' });
}
const user = db.prepare('SELECT id FROM discord_users WHERE id = ?').get(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
db.prepare('UPDATE discord_users SET role = ? WHERE id = ?').run(role, userId);
res.json({ message: 'Role updated' });
});
// Delete user
router.delete('/users/:id', authenticateToken, requireRole('superadmin'), (req, res) => {
const userId = parseInt(req.params.id);
if (userId === req.user.id) {
return res.status(400).json({ error: 'Cannot delete yourself' });
}
const user = db.prepare('SELECT id FROM discord_users WHERE id = ?').get(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
db.prepare('DELETE FROM discord_users WHERE id = ?').run(userId);
res.json({ message: 'User deleted' });
});
export default router;

View File

@@ -1,167 +0,0 @@
// 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';
}

View File

@@ -1,353 +0,0 @@
import { Client, GatewayIntentBits, EmbedBuilder } from 'discord.js';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { getServerStatus } from './ssh.js';
import { getPlayers, getPlayerList } from './rcon.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
let client = null;
let statusMessageId = null;
let statusChannelId = null;
let infoChannelId = null;
let alertChannelId = null;
// State tracking for alerts
const previousServerState = new Map();
const previousPlayerLists = new Map();
// Server display config
const serverDisplay = {
minecraft: { name: 'Minecraft ATM10', icon: '⛏️', color: 0x7B5E3C, address: 'minecraft.zeasy.dev' },
factorio: { name: 'Factorio', icon: '⚙️', color: 0xF97316, address: 'factorio.zeasy.dev' },
zomboid: { name: 'Project Zomboid', icon: '🧟', color: 0x4ADE80, address: 'zomboid.zeasy.dev' },
vrising: { name: 'V Rising', icon: '🧛', color: 0xDC2626, address: 'vrising.zeasy.dev' }
};
function loadConfig() {
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
}
async function sendAlert(embed) {
if (!client || !alertChannelId) return;
try {
const channel = await client.channels.fetch(alertChannelId);
if (channel) {
await channel.send({ embeds: [embed] });
}
} catch (err) {
console.error('[DiscordBot] Error sending alert:', err.message);
}
}
async function checkAndSendAlerts(serverStatuses) {
for (const server of serverStatuses) {
const display = serverDisplay[server.id] || { name: server.name, icon: '🖥️', color: 0x6B7280 };
const prevState = previousServerState.get(server.id);
const prevPlayers = previousPlayerLists.get(server.id) || [];
// Check server status changes
if (prevState !== undefined && prevState !== server.status) {
let embed;
if (server.status === 'online' && prevState !== 'online') {
embed = new EmbedBuilder()
.setTitle(display.icon + ' Server gestartet')
.setDescription('**' + display.name + '** ist jetzt online')
.setColor(0x22C55E)
.setTimestamp();
} else if (server.status === 'offline' && prevState === 'online') {
embed = new EmbedBuilder()
.setTitle(display.icon + ' Server gestoppt')
.setDescription('**' + display.name + '** ist jetzt offline')
.setColor(0xEF4444)
.setTimestamp();
} else if (server.status === 'unreachable' && prevState !== 'unreachable') {
embed = new EmbedBuilder()
.setTitle('⚠️ Server nicht erreichbar')
.setDescription('**' + display.name + '** ist nicht erreichbar')
.setColor(0xF59E0B)
.setTimestamp();
}
if (embed) {
await sendAlert(embed);
}
}
// Check player changes (only if server is online)
if (server.status === 'online' && server.playerList) {
const currentPlayers = server.playerList;
// Find players who joined
for (const player of currentPlayers) {
if (!prevPlayers.includes(player)) {
const embed = new EmbedBuilder()
.setTitle('➡️ Spieler beigetreten')
.setDescription('**' + player + '** hat **' + display.name + '** betreten')
.setColor(0x22C55E)
.setTimestamp();
await sendAlert(embed);
}
}
// Find players who left
for (const player of prevPlayers) {
if (!currentPlayers.includes(player)) {
const embed = new EmbedBuilder()
.setTitle('⬅️ Spieler verlassen')
.setDescription('**' + player + '** hat **' + display.name + '** verlassen')
.setColor(0xF59E0B)
.setTimestamp();
await sendAlert(embed);
}
}
previousPlayerLists.set(server.id, [...currentPlayers]);
} else {
previousPlayerLists.set(server.id, []);
}
// Update previous state
previousServerState.set(server.id, server.status);
}
}
async function setupInfoMessage() {
try {
const channel = await client.channels.fetch(infoChannelId);
if (!channel) {
console.error('[DiscordBot] Info channel not found');
return;
}
const messages = await channel.messages.fetch({ limit: 10 });
const botMessage = messages.find(m => m.author.id === client.user.id);
const infoEmbed = new EmbedBuilder()
.setTitle('🎮 Zeasy Gameserver Management')
.setDescription(
'Verwalte und überwache unsere Gameserver bequem über das Web-Interface.\n\n' +
'**Features:**\n' +
'• Server starten, stoppen & neustarten\n' +
'• Live Server-Status & Spielerlisten\n' +
'• Server-Konsole & RCON-Befehle\n' +
'• CPU, RAM & Uptime Metriken\n' +
'• Welten-Verwaltung (Factorio)\n' +
'• Config-Editor (Project Zomboid)\n\n' +
'**Zugang:**\n' +
'Melde dich mit deinem Discord-Account an. Deine Berechtigungen werden automatisch über deine Discord-Rollen bestimmt.'
)
.setColor(0x5865F2)
.addFields({
name: '🔗 Web-Interface',
value: '[server.zeasy.dev](https://server.zeasy.dev)',
inline: false
})
.setFooter({ text: 'Zeasy Software' })
.setTimestamp();
if (botMessage) {
await botMessage.edit({ embeds: [infoEmbed] });
console.log('[DiscordBot] Updated info message');
} else {
await channel.send({ embeds: [infoEmbed] });
console.log('[DiscordBot] Created info message');
}
} catch (err) {
console.error('[DiscordBot] Error setting up info message:', err.message);
}
}
export async function initDiscordBot() {
const token = process.env.DISCORD_BOT_TOKEN;
statusChannelId = process.env.DISCORD_STATUS_CHANNEL_ID;
infoChannelId = process.env.DISCORD_INFO_CHANNEL_ID;
alertChannelId = process.env.DISCORD_ALERT_CHANNEL_ID;
if (!token) {
console.log('[DiscordBot] Missing DISCORD_BOT_TOKEN, bot disabled');
return;
}
client = new Client({
intents: [GatewayIntentBits.Guilds]
});
client.once('ready', async () => {
console.log('[DiscordBot] Logged in as ' + client.user.tag);
// Setup info channel
if (infoChannelId) {
await setupInfoMessage();
}
// Setup status channel and alerts
if (statusChannelId) {
await findOrCreateStatusMessage();
// First run - just populate state without sending alerts
await updateStatusMessage(true);
// Then start regular updates with alerts
setInterval(() => updateStatusMessage(false), 60000);
}
});
client.login(token).catch(err => {
console.error('[DiscordBot] Failed to login:', err.message);
});
}
async function findOrCreateStatusMessage() {
try {
const channel = await client.channels.fetch(statusChannelId);
if (!channel) {
console.error('[DiscordBot] Status channel not found');
return;
}
const messages = await channel.messages.fetch({ limit: 10 });
const botMessage = messages.find(m => m.author.id === client.user.id);
if (botMessage) {
statusMessageId = botMessage.id;
console.log('[DiscordBot] Found existing status message');
} else {
const msg = await channel.send({ embeds: [createLoadingEmbed()] });
statusMessageId = msg.id;
console.log('[DiscordBot] Created new status message');
}
} catch (err) {
console.error('[DiscordBot] Error finding/creating status message:', err.message);
}
}
function createLoadingEmbed() {
return new EmbedBuilder()
.setTitle('🎮 Gameserver Status')
.setDescription('Lade Server-Status...')
.setColor(0x6B7280)
.setTimestamp();
}
async function updateStatusMessage(skipAlerts = false) {
if (!client || !statusMessageId || !statusChannelId) return;
try {
const channel = await client.channels.fetch(statusChannelId);
const message = await channel.messages.fetch(statusMessageId);
const config = loadConfig();
const serverStatuses = await Promise.all(config.servers.map(async (server) => {
try {
const status = await getServerStatus(server);
const running = status === 'online';
let players = { online: 0, max: null };
let playerList = { players: [] };
if (running && server.rconPassword) {
try {
players = await getPlayers(server);
playerList = await getPlayerList(server);
} catch (e) {
// RCON might fail
}
}
return {
id: server.id,
name: server.name,
type: server.type,
status: running ? 'online' : 'offline',
running,
players: players.online || 0,
maxPlayers: players.max,
playerList: playerList.players || []
};
} catch (err) {
return {
id: server.id,
name: server.name,
type: server.type,
status: 'unreachable',
running: false,
players: 0,
maxPlayers: null,
playerList: []
};
}
}));
// Send alerts if enabled
if (!skipAlerts && alertChannelId) {
await checkAndSendAlerts(serverStatuses);
} else if (skipAlerts) {
// Just populate initial state
for (const server of serverStatuses) {
previousServerState.set(server.id, server.status);
previousPlayerLists.set(server.id, server.playerList || []);
}
}
const embeds = [];
const onlineCount = serverStatuses.filter(s => s.running).length;
const totalPlayers = serverStatuses.reduce((sum, s) => sum + s.players, 0);
const headerEmbed = new EmbedBuilder()
.setTitle('🎮 Gameserver Status')
.setDescription('**' + onlineCount + '/' + serverStatuses.length + '** Server online • **' + totalPlayers + '** Spieler')
.setColor(onlineCount > 0 ? 0x22C55E : 0xEF4444)
.setTimestamp()
.setFooter({ text: 'Aktualisiert alle 60 Sekunden' });
embeds.push(headerEmbed);
for (const server of serverStatuses) {
const display = serverDisplay[server.id] || { name: server.name, icon: '🖥️', color: 0x6B7280, address: '' };
const serverEmbed = new EmbedBuilder()
.setTitle(display.icon + ' ' + display.name)
.setColor(server.running ? display.color : 0x4B5563);
if (server.running) {
let description = '✅ **Online**\n';
if (display.address) {
description += '```' + display.address + '```';
}
description += '👥 **Spieler:** ' + server.players;
if (server.maxPlayers) {
description += '/' + server.maxPlayers;
}
if (server.playerList && server.playerList.length > 0) {
const names = server.playerList.slice(0, 15).join(', ');
description += '\n' + names;
if (server.playerList.length > 15) {
description += ' *+' + (server.playerList.length - 15) + ' mehr*';
}
}
serverEmbed.setDescription(description);
} else {
serverEmbed.setDescription('❌ **Offline**');
}
embeds.push(serverEmbed);
}
await message.edit({ embeds });
} catch (err) {
console.error('[DiscordBot] Error updating status message:', err.message);
}
}
export function getDiscordClient() {
return client;
}

View File

@@ -64,7 +64,9 @@ Steam App IDs:
- V Rising: `1829350` - V Rising: `1829350`
- Factorio: `427520` (oder manuell von factorio.com) - Factorio: `427520` (oder manuell von factorio.com)
## Schritt 2: Systemd Service erstellen ## Schritt 2: Service erstellen (systemd oder PM2)
### Option A: Systemd Service (empfohlen für root-Zugang)
Erstelle `/etc/systemd/system/<spielname>.service`: Erstelle `/etc/systemd/system/<spielname>.service`:
@@ -92,6 +94,34 @@ systemctl daemon-reload
systemctl enable <spielname> systemctl enable <spielname>
``` ```
### Option B: PM2 (für User ohne root-Zugang)
PM2 eignet sich für Server, wo kein Root-Zugang verfügbar ist (z.B. externe VMs).
```bash
# NVM installieren
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.nvm/nvm.sh
# Node.js und PM2 installieren
nvm install --lts
npm install -g pm2
# Server starten (Beispiel Terraria)
cd /home/<user>/<spielordner>
pm2 start ./StartServer.sh --name <spielname>
# Speichern für Autostart
pm2 save
```
Für Autostart nach Reboot muss ein Admin den folgenden Befehl mit sudo ausführen:
```bash
sudo env PATH=$PATH:/home/<user>/.nvm/versions/node/<version>/bin pm2 startup systemd -u <user> --hp /home/<user>
```
In der GSM config.json `"runtime": "pm2"` und `"serviceName": "<pm2-prozessname>"` setzen.
## Schritt 3: Node Exporter installieren ## Schritt 3: Node Exporter installieren
Für Metriken im GSM Dashboard: Für Metriken im GSM Dashboard:
@@ -352,3 +382,4 @@ CREATE TABLE server_display_settings (
| V Rising | 192.168.2.52 | LXC | 9876-9877/UDP | | V Rising | 192.168.2.52 | LXC | 9876-9877/UDP |
| Palworld | 192.168.2.53 | LXC | 8211/UDP, 27015/UDP | | Palworld | 192.168.2.53 | LXC | 8211/UDP, 27015/UDP |
| Project Zomboid | 10.0.30.66 | VM (extern) | 16261-16262/UDP | | Project Zomboid | 10.0.30.66 | VM (extern) | 16261-16262/UDP |
| Terraria | 10.0.30.202 | VM (extern/VPN) | 7777/TCP |

View File

View File

@@ -200,9 +200,16 @@ router.get("/zomboid/config", authenticateToken, requireRole("moderator"), async
const server = config.servers.find(s => s.type === "zomboid"); const server = config.servers.find(s => s.type === "zomboid");
if (!server) return res.status(404).json({ error: "Zomboid server not configured" }); if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
}
const files = await listZomboidConfigs(server); const files = await listZomboidConfigs(server);
res.json({ files }); res.json({ files });
} catch (err) { } catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
@@ -419,10 +426,18 @@ router.get("/terraria/config", authenticateToken, requireRole("moderator"), asyn
const config = loadConfig(); const config = loadConfig();
const server = config.servers.find(s => s.id === "terraria"); const server = config.servers.find(s => s.id === "terraria");
if (!server) return res.status(404).json({ error: "Server not found" }); if (!server) return res.status(404).json({ error: "Server not found" });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
}
const content = await readTerrariaConfig(server); const content = await readTerrariaConfig(server);
res.json({ content }); res.json({ content });
} catch (error) { } catch (error) {
console.error("Error reading Terraria config:", error); console.error("Error reading Terraria config:", error);
if (error.message.includes('unreachable') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@@ -452,10 +467,18 @@ router.get("/openttd/config", authenticateToken, requireRole("moderator"), async
const config = loadConfig(); const config = loadConfig();
const server = config.servers.find(s => s.id === "openttd"); const server = config.servers.find(s => s.id === "openttd");
if (!server) return res.status(404).json({ error: "Server not found" }); if (!server) return res.status(404).json({ error: "Server not found" });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: "Server host is unreachable", unreachable: true });
}
const content = await readOpenTTDConfig(server); const content = await readOpenTTDConfig(server);
res.json({ content }); res.json({ content });
} catch (error) { } catch (error) {
console.error("Error reading OpenTTD config:", error); console.error("Error reading OpenTTD config:", error);
if (error.message.includes('unreachable') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@@ -487,11 +510,41 @@ router.get('/:id', optionalAuth, async (req, res) => {
} }
try { try {
// Check if host is unreachable
const hostUnreachable = isHostFailed(server.host, server.sshUser);
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 res.json({
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([ const [status, metrics, players, playerList, processUptime] = await Promise.all([
getServerStatus(server), getServerStatus(server),
getCurrentMetrics(server.id), getCurrentMetrics(server.id).catch(() => ({
server.rconPassword ? getPlayers(server) : { online: 0, max: null }, cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
server.rconPassword ? getPlayerList(server) : { players: [] }, })),
server.rconPassword ? getPlayers(server).catch(() => ({ online: 0, max: null })) : { online: 0, max: null },
server.rconPassword ? getPlayerList(server).catch(() => ({ players: [] })) : { players: [] },
getProcessUptime(server).catch(() => 0) getProcessUptime(server).catch(() => 0)
]); ]);
@@ -520,6 +573,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
hasRcon: !!server.rconPassword hasRcon: !!server.rconPassword
}); });
} catch (err) { } catch (err) {
console.error(`Error fetching server ${req.params.id}:`, err.message);
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
@@ -571,11 +625,18 @@ router.get('/:id/logs', authenticateToken, requireRole('moderator'), async (req,
const server = config.servers.find(s => s.id === req.params.id); const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' }); if (!server) return res.status(404).json({ error: 'Server not found' });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
try { try {
const lines = parseInt(req.query.lines) || 100; const lines = parseInt(req.query.lines) || 100;
const logs = await getConsoleLog(server, lines); const logs = await getConsoleLog(server, lines);
res.json({ logs }); res.json({ logs });
} catch (err) { } catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
@@ -586,12 +647,19 @@ router.post('/:id/start', authenticateToken, requireRole('moderator'), async (re
const server = config.servers.find(s => s.id === req.params.id); const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' }); if (!server) return res.status(404).json({ error: 'Server not found' });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
try { try {
const { save } = req.body || {}; const { save } = req.body || {};
await startServer(server, { save }); 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); 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' }); res.json({ message: 'Server starting' });
} catch (err) { } catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
@@ -601,11 +669,18 @@ router.post('/:id/stop', authenticateToken, requireRole('moderator'), async (req
const server = config.servers.find(s => s.id === req.params.id); const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' }); if (!server) return res.status(404).json({ error: 'Server not found' });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
try { try {
await stopServer(server); await stopServer(server);
logActivity(req.user.id, req.user.username, 'server_stop', server.id, null, req.user.discordId, req.user.avatar); logActivity(req.user.id, req.user.username, 'server_stop', server.id, null, req.user.discordId, req.user.avatar);
res.json({ message: 'Server stopping' }); res.json({ message: 'Server stopping' });
} catch (err) { } catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
@@ -615,11 +690,18 @@ router.post('/:id/restart', authenticateToken, requireRole('moderator'), async (
const server = config.servers.find(s => s.id === req.params.id); const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' }); if (!server) return res.status(404).json({ error: 'Server not found' });
if (isHostFailed(server.host, server.sshUser)) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
try { try {
await restartServer(server); await restartServer(server);
logActivity(req.user.id, req.user.username, 'server_restart', server.id, null, req.user.discordId, req.user.avatar); logActivity(req.user.id, req.user.username, 'server_restart', server.id, null, req.user.discordId, req.user.avatar);
res.json({ message: 'Server restarting' }); res.json({ message: 'Server restarting' });
} catch (err) { } catch (err) {
if (err.message.includes('unreachable') || err.message.includes('ECONNREFUSED') || err.message.includes('ETIMEDOUT')) {
return res.status(503).json({ error: 'Server host is unreachable', unreachable: true });
}
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });

View File

@@ -1,168 +0,0 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { getServers } from '../api'
import { useUser } from '../context/UserContext'
import ServerCard from '../components/ServerCard'
import SettingsModal from '../components/SettingsModal'
import UserManagement from '../components/UserManagement'
import LoginModal from '../components/LoginModal'
export default function Dashboard({ onLogin, onLogout }) {
const navigate = useNavigate()
const { user, token, loading: userLoading, isSuperadmin, role } = useUser()
const [servers, setServers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showSettings, setShowSettings] = useState(false)
const [showUserMgmt, setShowUserMgmt] = useState(false)
const [showLogin, setShowLogin] = useState(false)
const isAuthenticated = !!token
const fetchServers = async () => {
try {
const data = await getServers(token)
setServers(data)
setError('')
} catch (err) {
if (err.message.includes('401') || err.message.includes('403')) {
if (isAuthenticated) {
onLogout()
}
} else {
setError('Verbindung zum Server fehlgeschlagen')
}
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!userLoading) {
fetchServers()
const interval = setInterval(fetchServers, 10000)
return () => clearInterval(interval)
}
}, [token, userLoading])
const roleLabels = {
user: 'Betrachter',
moderator: 'Operator',
superadmin: 'Admin'
}
if (userLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-neutral-400">Laden...</div>
</div>
)
}
const onlineCount = servers.filter(s => s.running).length
document.title = 'Dashboard | Zeasy GSM'
const totalPlayers = servers.reduce((sum, s) => sum + (s.players?.online || 0), 0)
return (
<div className="min-h-screen page-enter">
{/* Header */}
<header className="border-b border-neutral-800 bg-neutral-900/50 backdrop-blur-sm sticky top-0 z-10">
<div className="container-main py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<a href="https://zeasy.software" target="_blank" rel="noopener noreferrer" className="relative block group"><img src="/navbarlogograuer.png" alt="Logo" className="h-8 transition-opacity duration-300 group-hover:opacity-0" /><img src="/navbarlogoweiß.png" alt="Logo" className="h-8 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" /></a>
<span className="text-xl font-semibold text-white hidden sm:inline">Gameserver Management</span>
</div>
<div className="hidden md:flex items-center gap-4 text-sm text-neutral-400">
<span>
<span className="text-white font-medium">{onlineCount}</span>/{servers.length} online
</span>
<span className="text-neutral-600">|</span>
<span>
<span className="text-white font-medium">{totalPlayers}</span> Spieler
</span>
</div>
<div className="flex items-center gap-3">
{isAuthenticated ? (
<>
<div className="hidden sm:block text-right mr-2">
<div className="text-sm text-white">{user?.username}</div>
<div className="text-xs text-neutral-500">{roleLabels[role]}</div>
</div>
{isSuperadmin && (
<button
onClick={() => setShowUserMgmt(true)}
className="btn btn-ghost"
>
Benutzer
</button>
)}
<button
onClick={() => setShowSettings(true)}
className="btn btn-ghost"
>
Einstellungen
</button>
<button
onClick={onLogout}
className="btn btn-outline"
>
Abmelden
</button>
</>
) : (
<button
onClick={() => setShowLogin(true)}
className="btn btn-primary"
>
Anmelden
</button>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="container-main py-8">
{error && (
<div className="mb-6 alert alert-error fade-in">
{error}
</div>
)}
{loading ? (
<div className="text-center py-12">
<div className="text-neutral-400">Server werden geladen...</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{servers.map((server, index) => (
<div
key={server.id}
className="fade-in-up"
style={{ animationDelay: index * 50 + 'ms', animationFillMode: 'both' }}
>
<ServerCard
server={server}
onClick={() => navigate('/server/' + server.id)}
isAuthenticated={isAuthenticated}
/>
</div>
))}
</div>
)}
</main>
{/* Modals */}
{showSettings && (
<SettingsModal onClose={() => setShowSettings(false)} />
)}
{showUserMgmt && (
<UserManagement onClose={() => setShowUserMgmt(false)} />
)}
{showLogin && (
<LoginModal onLogin={onLogin} onClose={() => setShowLogin(false)} />
)}
</div>
)
}

View File

@@ -1,208 +0,0 @@
const serverInfo = {
minecraft: {
address: 'minecraft.dimension47.de',
logo: '/minecraft.png',
links: [
{ label: 'ATM10 Modpack', url: 'https://www.curseforge.com/minecraft/modpacks/all-the-mods-10' }
]
},
factorio: {
hint: 'Serverpasswort: affe',
address: 'factorio.dimension47.de',
logo: '/factorio.png',
links: [
{ label: 'Steam', url: 'https://store.steampowered.com/app/427520/Factorio/' }
]
},
vrising: {
address: 'Zeasy Software Vampire',
logo: '/vrising.png',
links: [
{ label: 'Steam', url: 'https://store.steampowered.com/app/1604030/V_Rising/' }
]
},
zomboid: {
hint: 'Version 42.13',
address: 'pz.zeasy.dev:16261',
logo: '/zomboid.png',
links: [
{ label: 'Steam', url: 'https://store.steampowered.com/app/108600/Project_Zomboid/' }
]
}
}
const getServerInfo = (serverName) => {
const name = serverName.toLowerCase()
if (name.includes('minecraft') || name.includes('all the mods')) return serverInfo.minecraft
if (name.includes('factorio')) return serverInfo.factorio
if (name.includes('vrising') || name.includes('v rising')) return serverInfo.vrising
if (name.includes('zomboid')) return serverInfo.zomboid
return null
}
export default function ServerCard({ server, onClick, isAuthenticated }) {
const info = getServerInfo(server.name)
const formatUptime = (seconds) => {
const hours = Math.floor(seconds / 3600)
if (hours > 24) {
const days = Math.floor(hours / 24)
return days + 'd ' + (hours % 24) + 'h'
}
const minutes = Math.floor((seconds % 3600) / 60)
return hours + 'h ' + minutes + 'm'
}
const cpuPercent = Math.min(server.metrics.cpu, 100)
const memPercent = Math.min(server.metrics.memory, 100)
const getProgressColor = (percent) => {
if (percent > 80) return 'progress-bar-danger'
if (percent > 60) return 'progress-bar-warning'
return 'progress-bar-success'
}
const getStatusBadge = () => {
const status = server.status || (server.running ? 'online' : 'offline')
switch (status) {
case 'online':
return { class: 'badge badge-success', text: 'Online' }
case 'starting':
return { class: 'badge badge-warning', text: 'Startet...' }
case 'stopping':
return { class: 'badge badge-warning', text: 'Stoppt...' }
case 'unreachable':
return { class: 'badge badge-muted', text: 'Nicht erreichbar' }
default:
return { class: 'badge badge-destructive', text: 'Offline' }
}
}
const statusBadge = getStatusBadge()
return (
<div
className={`card ${server.status !== "unreachable" ? "card-clickable" : "cursor-not-allowed"} p-5 ${server.status === "unreachable" ? "opacity-50" : ""}`}
onClick={server.status !== "unreachable" ? onClick : undefined}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
{info && info.logo && <img src={info.logo} alt="" className="h-8 w-8 object-contain" />}
<h3 className="text-lg font-semibold text-white">{server.name}</h3>
</div>
<span className={statusBadge.class}>
{statusBadge.text}
</span>
</div>
{/* Server Address & Links */}
{info && (
<div className="mb-4 flex items-center gap-3 text-sm">
<code className="text-neutral-400 bg-neutral-800 px-2 py-0.5 rounded">
{info.address}
</code>
{info.links.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-blue-400 hover:text-blue-300 hover:underline"
>
{link.label}
</a>
))}
</div>
)}
{/* Whitelist notice for Minecraft - only for authenticated users */}
{isAuthenticated && server.type === 'minecraft' && (
<div className="mb-4 text-xs text-neutral-500">
Whitelist erforderlich - im Whitelist-Tab freischalten
</div>
)}
{/* Factorio notice - only for authenticated users */}
{isAuthenticated && server.type === 'factorio' && (
<div className="mb-4 text-xs text-neutral-500">
Serverpasswort: affe
</div>
)}
{/* V Rising notice - only for authenticated users */}
{isAuthenticated && server.type === 'vrising' && (
<div className="mb-4 text-xs text-neutral-500">
In der Serverliste suchen - Passwort: affe
</div>
)}
{/* Project Zomboid notice - only for authenticated users */}
{isAuthenticated && server.type === 'zomboid' && (
<div className="mb-4 text-xs text-neutral-500">
Version 42.13
</div>
)}
{/* Metrics */}
<div className="space-y-3">
{/* CPU */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-400">CPU</span>
<span className="text-white">{server.metrics.cpu.toFixed(1)}%</span>
</div>
<div className="progress">
<div
className={'progress-bar ' + getProgressColor(cpuPercent)}
style={{ width: cpuPercent + '%' }}
/>
</div>
</div>
{/* RAM */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-400">Arbeitsspeicher</span>
<span className="text-white">
{server.metrics.memoryUsed?.toFixed(1) || 0} / {server.metrics.memoryTotal?.toFixed(1) || 0} {server.metrics.memoryUnit}
</span>
</div>
<div className="progress">
<div
className={'progress-bar ' + getProgressColor(memPercent)}
style={{ width: memPercent + '%' }}
/>
</div>
</div>
</div>
{/* Footer Stats */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-neutral-800 text-sm">
<div className="text-neutral-400">
<span className="text-white font-medium">{server.players.online}</span>
{server.players.max ? ' / ' + server.players.max : ''} Spieler
</div>
{server.running && (
<div className="text-neutral-400">
Laufzeit: <span className="text-white">{formatUptime(server.metrics.uptime)}</span>
</div>
)}
</div>
{/* Players List */}
{server.players?.list?.length > 0 && (
<div className="mt-3 pt-3 border-t border-neutral-800">
<div className="flex flex-wrap gap-1.5">
{server.players.list.map((player, i) => (
<span key={i} className="badge badge-secondary">
{player}
</span>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,640 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { getServers, serverAction, sendRcon, getServerLogs, getWhitelist, getFactorioCurrentSave, getAutoShutdownSettings, setAutoShutdownSettings } from '../api'
import { useUser } from '../context/UserContext'
import MetricsChart from '../components/MetricsChart'
import FactorioWorldManager from '../components/FactorioWorldManager'
const getServerLogo = (serverName) => {
const name = serverName.toLowerCase()
if (name.includes("minecraft") || name.includes("all the mods")) return "/minecraft.png"
if (name.includes("factorio")) return "/factorio.png"
if (name.includes("vrising") || name.includes("v rising")) return "/vrising.png"
if (name.includes("zomboid")) return "/zomboid.png"
return null
}
export default function ServerDetail() {
const { serverId } = useParams()
const navigate = useNavigate()
const { token, isModerator } = useUser()
const [server, setServer] = useState(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('overview')
const [rconCommand, setRconCommand] = useState('')
const [rconHistory, setRconHistory] = useState([])
const [logs, setLogs] = useState('')
const [whitelistPlayers, setWhitelistPlayers] = useState([])
const [whitelistInput, setWhitelistInput] = useState('')
const [whitelistLoading, setWhitelistLoading] = useState(false)
const [currentSave, setCurrentSave] = useState(null)
const [logsUpdated, setLogsUpdated] = useState(null)
const logsRef = useRef(null)
const rconRef = useRef(null)
// Auto-shutdown state
const [autoShutdown, setAutoShutdown] = useState({ enabled: false, timeoutMinutes: 15, emptySinceMinutes: null })
const [autoShutdownLoading, setAutoShutdownLoading] = useState(false)
const fetchCurrentSave = async () => {
if (token && serverId === 'factorio') {
try {
const result = await getFactorioCurrentSave(token)
setCurrentSave(result)
} catch (err) {
console.error('Failed to fetch current save:', err)
}
}
}
const fetchServer = async () => {
try {
const servers = await getServers(token)
const found = servers.find(s => s.id === serverId)
if (found) {
setServer(found); document.title = found.name + " | Zeasy GSM"
} else {
navigate('/')
}
} catch (err) {
console.error(err)
navigate('/')
} finally {
setLoading(false)
}
}
const fetchAutoShutdownSettings = async () => {
if (token && serverId) {
try {
const data = await getAutoShutdownSettings(token, serverId)
setAutoShutdown(data)
} catch (err) {
console.error('Failed to load auto-shutdown settings:', err)
}
}
}
const handleAutoShutdownToggle = async () => {
setAutoShutdownLoading(true)
try {
await setAutoShutdownSettings(token, serverId, !autoShutdown.enabled, autoShutdown.timeoutMinutes)
setAutoShutdown(prev => ({ ...prev, enabled: !prev.enabled }))
} catch (err) {
console.error(err)
}
setAutoShutdownLoading(false)
}
const handleAutoShutdownTimeoutChange = async (newTimeout) => {
const timeout = Math.max(1, Math.min(1440, parseInt(newTimeout) || 15))
setAutoShutdown(prev => ({ ...prev, timeoutMinutes: timeout }))
clearTimeout(window.autoShutdownSaveTimeout)
window.autoShutdownSaveTimeout = setTimeout(async () => {
try {
await setAutoShutdownSettings(token, serverId, autoShutdown.enabled, timeout)
} catch (err) {
console.error(err)
}
}, 500)
}
useEffect(() => {
fetchServer()
fetchCurrentSave()
const interval = setInterval(() => {
fetchServer()
fetchCurrentSave()
}, 10000)
return () => clearInterval(interval)
}, [token, serverId])
useEffect(() => {
if (activeTab === 'settings' && isModerator) {
fetchAutoShutdownSettings()
const interval = setInterval(fetchAutoShutdownSettings, 10000)
return () => clearInterval(interval)
}
}, [activeTab, isModerator, token, serverId])
const handleAction = async (action) => {
// Immediately set status locally
const newStatus = action === 'start' ? 'starting' : (action === 'stop' ? 'stopping' : 'starting')
setServer(prev => ({ ...prev, status: newStatus }))
try {
await serverAction(token, server.id, action)
setTimeout(() => {
fetchServer()
}, 2000)
} catch (err) {
console.error(err)
}
}
const handleRcon = async (e) => {
e.preventDefault()
if (!rconCommand.trim()) return
const cmd = rconCommand
setRconCommand('')
try {
const { response } = await sendRcon(token, server.id, cmd)
setRconHistory([...rconHistory, { cmd, res: response, time: new Date() }])
} catch (err) {
setRconHistory([...rconHistory, { cmd, res: 'Error: ' + err.message, time: new Date(), error: true }])
}
}
const fetchLogs = async () => {
try {
const data = await getServerLogs(token, server.id, 50)
setLogs(data.logs || '')
setLogsUpdated(new Date())
if (logsRef.current) {
logsRef.current.scrollTop = logsRef.current.scrollHeight
}
} catch (err) {
console.error(err)
}
}
useEffect(() => {
if (activeTab === 'console' && isModerator && server) {
fetchLogs()
const interval = setInterval(fetchLogs, 5000)
return () => clearInterval(interval)
}
}, [activeTab, isModerator, server])
useEffect(() => {
if (activeTab === 'whitelist' && isModerator && server?.type === 'minecraft') {
fetchWhitelist()
}
}, [activeTab, server])
useEffect(() => {
if (rconRef.current) {
rconRef.current.scrollTop = rconRef.current.scrollHeight
}
}, [rconHistory])
const fetchWhitelist = async () => {
if (!server?.hasRcon) return
try {
const { players } = await getWhitelist(token, server.id)
setWhitelistPlayers(players)
} catch (err) {
console.error("Failed to fetch whitelist:", err)
}
}
const addToWhitelist = async (e) => {
e.preventDefault()
if (!whitelistInput.trim()) return
setWhitelistLoading(true)
try {
await sendRcon(token, server.id, 'whitelist add ' + whitelistInput.trim())
setWhitelistInput('')
await fetchWhitelist()
} catch (err) {
console.error('Failed to add to whitelist:', err)
} finally {
setWhitelistLoading(false)
}
}
const removeFromWhitelist = async (player) => {
setWhitelistLoading(true)
try {
await sendRcon(token, server.id, 'whitelist remove ' + player)
await fetchWhitelist()
} catch (err) {
console.error('Failed to remove from whitelist:', err)
} finally {
setWhitelistLoading(false)
}
}
const formatUptime = (seconds) => {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return days + 'd ' + hours + 'h ' + minutes + 'm'
return hours + 'h ' + minutes + 'm'
}
const tabs = [
{ id: 'overview', label: 'Übersicht' },
{ id: 'metrics', label: 'Metriken' },
...(isModerator ? [
{ id: 'console', label: 'Konsole' },
] : []),
...(isModerator && server?.type === 'minecraft' ? [
{ id: 'whitelist', label: 'Whitelist' },
] : []),
...(isModerator && server?.type === 'factorio' ? [
{ id: 'worlds', label: 'Welten' },
] : []),
...(isModerator ? [
{ id: 'settings', label: 'Einstellungen' },
] : []),
]
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-neutral-400">Laden...</div>
</div>
)
}
if (!server) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-neutral-400">Server nicht gefunden</div>
</div>
)
}
const cpuPercent = Math.min(server.metrics.cpu, 100)
const memPercent = Math.min(server.metrics.memory, 100)
const getStatusBadge = () => {
const status = server.status || (server.running ? 'online' : 'offline')
switch (status) {
case 'online':
return { class: 'badge badge-success', text: 'Online' }
case 'starting':
return { class: 'badge badge-warning', text: 'Startet...' }
case 'stopping':
return { class: 'badge badge-warning', text: 'Stoppt...' }
default:
return { class: 'badge badge-destructive', text: 'Offline' }
}
}
const statusBadge = getStatusBadge()
return (
<div className="min-h-screen page-enter">
{/* Header */}
<header className="border-b border-neutral-800 bg-neutral-900/50 backdrop-blur-sm sticky top-0 z-10">
<div className="container-main py-4">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/')}
className="btn btn-ghost"
>
Zurück
</button>
<div className="flex-1">
<div className="flex items-center gap-3">
{getServerLogo(server.name) && <img src={getServerLogo(server.name)} alt="" className="h-8 w-8 object-contain" />}
<h1 className="text-xl font-semibold text-white">{server.name}</h1>
<span className={statusBadge.class}>
{statusBadge.text}
</span>
</div>
{server.running && (
<p className="text-sm text-neutral-400 mt-1">
Laufzeit: {formatUptime(server.metrics.uptime)}
</p>
)}
</div>
</div>
{/* Tabs */}
<div className="tabs mt-4">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={'tab ' + (activeTab === tab.id ? 'tab-active' : '')}
>
{tab.label}
</button>
))}
</div>
</div>
</header>
{/* Content */}
<main className="container-main py-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6 tab-content">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="card p-4">
<div className="text-sm text-neutral-400">CPU Auslastung</div>
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpu.toFixed(1)}%</div>
<div className="progress mt-2">
<div className="progress-bar" style={{ width: cpuPercent + '%' }} />
</div>
</div>
<div className="card p-4">
<div className="text-sm text-neutral-400">Arbeitsspeicher</div>
<div className="text-2xl font-semibold text-white mt-1">
{server.metrics.memoryUsed?.toFixed(1)} {server.metrics.memoryUnit}
</div>
<div className="text-xs text-neutral-500 mt-1">
von {server.metrics.memoryTotal?.toFixed(1)} {server.metrics.memoryUnit}
</div>
</div>
<div className="card p-4">
<div className="text-sm text-neutral-400">Spieler</div>
<div className="text-2xl font-semibold text-white mt-1">{server.players.online}</div>
<div className="text-xs text-neutral-500 mt-1">
{server.players.max ? 'von ' + server.players.max + ' max' : 'Kein Limit'}
</div>
</div>
<div className="card p-4">
<div className="text-sm text-neutral-400">CPU Kerne</div>
<div className="text-2xl font-semibold text-white mt-1">{server.metrics.cpuCores}</div>
</div>
{server.type === 'factorio' && currentSave?.save && (
<div className="card p-4">
<div className="text-sm text-neutral-400">{server.running ? 'Aktuelle Welt' : 'Nächste Welt'}</div>
<div className="text-lg font-semibold text-white mt-1 truncate">{currentSave.save}</div>
{!server.running && currentSave.source === 'newest' && (
<div className="text-xs text-neutral-500 mt-1">neuester Speicherstand</div>
)}
</div>
)}
</div>
{/* Players List */}
{server.players?.list?.length > 0 && (
<div className="card p-4">
<h3 className="text-sm font-medium text-neutral-300 mb-3">Spieler Online</h3>
<div className="flex flex-wrap gap-2">
{server.players.list.map((player, i) => (
<span key={i} className="badge badge-secondary">{player}</span>
))}
</div>
</div>
)}
{/* Power Controls */}
{isModerator && (
<div className="card p-4">
<h3 className="text-sm font-medium text-neutral-300 mb-3">Server Steuerung</h3>
<div className="flex flex-wrap gap-3">
{(server.status === 'online' || (server.running && server.status !== 'starting' && server.status !== 'stopping')) ? (
<>
<button
onClick={() => handleAction('stop')}
disabled={server.status === 'stopping' || server.status === 'starting'}
className="btn btn-destructive"
>
{server.status === 'stopping' ? 'Stoppt...' : 'Server Stoppen'}
</button>
<button
onClick={() => handleAction('restart')}
disabled={server.status === 'stopping' || server.status === 'starting'}
className="btn btn-secondary"
>
{server.status === 'starting' ? 'Startet...' : 'Server Neustarten'}
</button>
</>
) : (
<button
onClick={() => handleAction('start')}
disabled={server.status === 'stopping' || server.status === 'starting'}
className="btn btn-primary"
>
{server.status === 'starting' ? 'Startet...' : 'Server Starten'}
</button>
)}
</div>
</div>
)}
</div>
)}
{/* Metrics Tab */}
{activeTab === 'metrics' && (
<div className="tab-content"><MetricsChart serverId={server.id} serverName={server.name} expanded={true} /></div>
)}
{/* Console Tab - Logs + RCON */}
{activeTab === 'console' && isModerator && (
<div className="space-y-4 tab-content">
{/* Logs */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-400">Server Logs (letzte 50 Zeilen)</span>
{logsUpdated && (
<span className="text-xs text-neutral-600">
Aktualisiert: {logsUpdated.toLocaleTimeString('de-DE')}
</span>
)}
</div>
<button onClick={fetchLogs} className="btn btn-secondary">
Aktualisieren
</button>
</div>
<div
ref={logsRef}
className="terminal p-4 logs-container text-xs text-neutral-300 whitespace-pre-wrap"
>
{logs || 'Laden...'}
</div>
{/* RCON History */}
{rconHistory.length > 0 && (
<div ref={rconRef} className="terminal p-4 max-h-40 overflow-y-auto">
<div className="text-neutral-500 text-xs mb-2">RCON Verlauf</div>
{rconHistory.map((entry, i) => (
<div key={i} className="mb-2 text-sm">
<div className="text-neutral-400">
<span className="text-neutral-600">[{entry.time.toLocaleTimeString('de-DE')}]</span> &gt; {entry.cmd}
</div>
<div className={'whitespace-pre-wrap pl-4 ' + (entry.error ? 'text-red-400' : 'text-neutral-300')}>
{entry.res}
</div>
</div>
))}
</div>
)}
{/* RCON Input */}
{server.hasRcon && (
<form onSubmit={handleRcon} className="flex gap-2">
<input
type="text"
value={rconCommand}
onChange={(e) => setRconCommand(e.target.value)}
placeholder="RCON Befehl..."
className="input flex-1"
/>
<button type="submit" className="btn btn-primary">
Senden
</button>
</form>
)}
</div>
)}
{/* Whitelist Tab - Minecraft only */}
{activeTab === 'whitelist' && isModerator && server.type === 'minecraft' && (
<div className="space-y-4 tab-content">
<div className="card p-4">
<h3 className="text-sm font-medium text-neutral-300 mb-3">Zur Whitelist hinzufügen</h3>
<form onSubmit={addToWhitelist} className="flex gap-2">
<input
type="text"
value={whitelistInput}
onChange={(e) => setWhitelistInput(e.target.value)}
placeholder="Minecraft Benutzername..."
className="input flex-1"
disabled={whitelistLoading || !server.running}
/>
<button type="submit" className="btn btn-primary" disabled={whitelistLoading || !server.running}>
{whitelistLoading ? 'Wird hinzugefügt...' : 'Hinzufügen'}
</button>
</form>
</div>
<div className="card p-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-medium text-neutral-300">Whitelist Spieler ({whitelistPlayers.length})</h3>
<button onClick={fetchWhitelist} className="btn btn-ghost text-sm">
Aktualisieren
</button>
</div>
{whitelistPlayers.length > 0 ? (
<div className="flex flex-wrap gap-2">
{whitelistPlayers.map((player, i) => (
<div key={i} className="flex items-center gap-1 bg-neutral-800 rounded-full pl-3 pr-1 py-1">
<span className="text-sm text-neutral-200">{player}</span>
<button
onClick={() => removeFromWhitelist(player)}
disabled={whitelistLoading || !server.running}
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-neutral-700 text-neutral-400 hover:text-red-400"
>
x
</button>
</div>
))}
</div>
) : (
<div className="text-neutral-500 text-sm">Keine Spieler auf der Whitelist</div>
)}
</div>
</div>
)}
{/* Worlds Tab - Factorio only */}
{activeTab === 'worlds' && isModerator && server.type === 'factorio' && (
<div className="tab-content">
{server.running || server.status === 'starting' || server.status === 'stopping' ? (
<div className="card p-8 text-center">
<div className="text-neutral-400 mb-2">Weltverwaltung ist gesperrt während der Server läuft</div>
<div className="text-neutral-500 text-sm">Server stoppen um Speicherstände zu verwalten</div>
</div>
) : (
<FactorioWorldManager
server={server}
token={token}
onServerAction={fetchServer}
/>
)}
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && isModerator && (
<div className="space-y-4 tab-content">
<div className="card p-4">
<h3 className="text-sm font-medium text-neutral-300 mb-4">Auto-Shutdown</h3>
<p className="text-neutral-500 text-sm mb-4">
Server automatisch stoppen wenn keine Spieler online sind
</p>
<div className="space-y-4">
{/* Toggle Switch */}
<div className="flex items-center gap-3">
<button
onClick={handleAutoShutdownToggle}
disabled={autoShutdownLoading}
className={`
relative w-14 h-8 rounded-full transition-all duration-200
${autoShutdown.enabled
? 'bg-green-600 border-green-500'
: 'bg-neutral-700 border-neutral-600'
}
border-2 focus:outline-none focus:ring-2 focus:ring-neutral-500
${autoShutdownLoading ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<span
className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow-md
transition-all duration-200 ease-in-out
${autoShutdown.enabled ? 'left-7' : 'left-1'}
`}
/>
</button>
<span className={`text-sm font-medium ${autoShutdown.enabled ? 'text-green-500' : 'text-neutral-500'}`}>
{autoShutdown.enabled ? 'Aktiviert' : 'Deaktiviert'}
</span>
</div>
{/* Timeout mit +/- Buttons */}
{autoShutdown.enabled && (
<div className="flex items-center gap-4 pt-2">
<span className="text-neutral-400 text-sm">Timeout:</span>
<div className="flex items-center gap-1">
<button
onClick={() => handleAutoShutdownTimeoutChange(autoShutdown.timeoutMinutes - 5)}
disabled={autoShutdown.timeoutMinutes <= 5}
className="btn btn-secondary px-3"
>
-
</button>
<span className="text-white text-lg font-medium w-16 text-center">
{autoShutdown.timeoutMinutes}
</span>
<button
onClick={() => handleAutoShutdownTimeoutChange(autoShutdown.timeoutMinutes + 5)}
disabled={autoShutdown.timeoutMinutes >= 1440}
className="btn btn-secondary px-3"
>
+
</button>
</div>
<span className="text-neutral-500 text-sm">Minuten</span>
</div>
)}
{/* Status */}
{autoShutdown.enabled && server.running && (
<div className="border-t border-neutral-800 pt-4 mt-2">
<div className="flex items-center gap-3">
<span className="text-neutral-400 text-sm">Status:</span>
{autoShutdown.emptySinceMinutes !== null ? (
<span className="badge badge-warning">
Leer seit {autoShutdown.emptySinceMinutes} Min. Shutdown in {autoShutdown.timeoutMinutes - autoShutdown.emptySinceMinutes} Min.
</span>
) : (
<span className="badge badge-success">
Spieler online
</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
)}
</main>
</div>
)
}

View File

@@ -1,86 +0,0 @@
// ============ 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;
}