zustand auf server wiederhergestellt

This commit is contained in:
2026-01-09 08:43:18 +01:00
parent 1010fe7d11
commit f2f9e02fb2
30 changed files with 6403 additions and 139 deletions

69
HANDOFF-SATISFACTORY.md Normal file
View File

@@ -0,0 +1,69 @@
# Handoff: Satisfactory Server Setup
**Datum**: 2026-01-08
**Status**: Windows VM Installation blockiert
## Aktueller Stand
### Was passiert ist
1. Linux LXC mit Docker für Satisfactory aufgesetzt
2. Festgestellt: **SatisfactoryPlus Mod ist nur für Windows** verfügbar (kein LinuxServer Build)
3. LXC 105 wieder gelöscht
4. Windows Server 2022 VM Erstellung gestartet
5. **Problem**: Windows Installer findet keine Festplatte (VirtIO Treiber werden nicht erkannt)
### Das Problem
- Windows Installer zeigt "no signed device drivers were found"
- VirtIO ISO eingebunden, aber Treiber werden nicht akzeptiert
- Vermutlich Secure Boot oder falscher Controller-Typ
## Nächste Schritte morgen
### 1. VM-Konfiguration prüfen
```bash
ssh root@192.168.2.20 "qm config <VM-ID>"
```
- Welche VM-ID wurde verwendet? (nicht mehr 105)
- Prüfen: BIOS-Typ (SeaBIOS vs OVMF/UEFI)
- Prüfen: Secure Boot aktiviert?
### 2. Lösungsoptionen
**Option A: SeaBIOS statt UEFI**
- VM → Options → BIOS → SeaBIOS
- Kein Secure Boot Problem
**Option B: IDE Controller verwenden**
- Hard Disk detachen
- Neu hinzufügen mit Bus: IDE
- Langsamer aber funktioniert ohne Treiber
**Option C: VirtIO mit korrektem Treiber**
- Neueste VirtIO ISO: https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso
- Im Installer: Treiber laden → `viostor\2k22\amd64` (nicht vioscsi!)
### 3. Nach Windows Installation
- [ ] VirtIO Guest Tools installieren (`virtio-win-guest-tools.exe`)
- [ ] QEMU Guest Agent aktivieren
- [ ] Statische IP 192.168.2.54 setzen
- [ ] OpenSSH Server installieren (für GSM)
- [ ] Node Exporter für Windows installieren
- [ ] Satisfactory Dedicated Server installieren (Steam)
- [ ] Satisfactory Mod Manager (SMM) installieren
- [ ] SatisfactoryPlus Mod installieren
- [ ] GSM config.json erweitern
- [ ] Firewall Ports öffnen (7777, 15777, 15000)
## VM Empfohlene Specs
| Setting | Wert |
|---------|------|
| OS | Windows Server 2022 mit GUI |
| CPU | 4-6 vCPU |
| RAM | 20-24 GB |
| Disk | 100 GB |
| IP | 192.168.2.54 |
| BIOS | SeaBIOS (einfacher) |
## Relevante Dateien
- GSM Config: `/opt/gameserver-monitor/backend/config.json` (auf 192.168.2.30)
- Doku: `docs/satisfactory.md` (muss neu erstellt werden nach Setup)

30
auth.js
View File

@@ -9,6 +9,26 @@ const router = Router();
// Initialize Discord users table // Initialize Discord users table
initDiscordUsers(); 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 ===== // ===== Discord OAuth2 =====
// Start Discord OAuth2 flow // Start Discord OAuth2 flow
@@ -99,6 +119,16 @@ router.get('/discord/callback', async (req, res) => {
// Get current user info // Get current user info
router.get('/me', authenticateToken, (req, res) => { 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 // Check if it's a Discord user
if (req.user.discordId) { if (req.user.discordId) {
const user = db.prepare('SELECT id, discord_id, username, avatar, role FROM discord_users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT id, discord_id, username, avatar, role FROM discord_users WHERE id = ?').get(req.user.id);

417
docs/gitea-setup.md Normal file
View File

@@ -0,0 +1,417 @@
# Gitea Setup mit CI/CD Runner
Lokale Git-Instanz auf Proxmox LXC mit automatischem Deployment via Gitea Actions.
## Übersicht
```
┌─────────────────┐ git push ┌─────────────────┐
│ Lokaler PC │ ───────────────▶ │ Gitea LXC │
│ (Development) │ │ 192.168.2.40 │
└─────────────────┘ └────────┬────────┘
│ trigger
┌─────────────────┐
│ Gitea Runner │
│ (act_runner) │
└────────┬────────┘
│ SSH deploy
┌─────────────────┐
│ GSM Server │
│ 192.168.2.30 │
└─────────────────┘
```
## Teil 1: LXC Container erstellen
### Proxmox Web UI
1. CT Template herunterladen: `Datacenter → pve → local → CT Templates → Templates`
- Debian 12 (Bookworm) empfohlen
2. Neuen Container erstellen:
- **CT ID:** 104 (oder nächste freie)
- **Hostname:** gitea
- **Password:** sicheres Root-Passwort
- **Template:** debian-12-standard
- **Disk:** 16 GB
- **CPU:** 2 Cores
- **RAM:** 1024 MB
- **Network:** vmbr0, DHCP oder statisch 192.168.2.40
### Oder per CLI auf Proxmox Host
```bash
# Template herunterladen falls nicht vorhanden
pveam download local debian-12-standard_12.2-1_amd64.tar.zst
# Container erstellen
pct create 104 local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst \
--hostname gitea \
--memory 1024 \
--cores 2 \
--rootfs local-lvm:16 \
--net0 name=eth0,bridge=vmbr0,ip=192.168.2.40/24,gw=192.168.2.1 \
--password \
--unprivileged 1 \
--features nesting=1
# Container starten
pct start 104
```
## Teil 2: Gitea Installation
### System vorbereiten
```bash
# In den Container einloggen
pct enter 104
# System updaten
apt update && apt upgrade -y
# Abhängigkeiten installieren
apt install -y git curl wget sudo sqlite3
```
### Git-User erstellen
```bash
adduser --system --shell /bin/bash --group --disabled-password --home /home/git git
```
### Gitea herunterladen
```bash
# Aktuelle Version prüfen: https://github.com/go-gitea/gitea/releases
GITEA_VERSION="1.21.4"
wget -O /usr/local/bin/gitea https://dl.gitea.io/gitea/${GITEA_VERSION}/gitea-${GITEA_VERSION}-linux-amd64
chmod +x /usr/local/bin/gitea
# Version prüfen
gitea --version
```
### Verzeichnisse erstellen
```bash
mkdir -p /var/lib/gitea/{custom,data,log}
mkdir -p /etc/gitea
chown -R git:git /var/lib/gitea
chown root:git /etc/gitea
chmod 770 /etc/gitea
```
### Systemd Service
```bash
cat > /etc/systemd/system/gitea.service << 'EOF'
[Unit]
Description=Gitea (Git with a cup of tea)
After=network.target
[Service]
RestartSec=2s
Type=simple
User=git
Group=git
WorkingDirectory=/var/lib/gitea/
ExecStart=/usr/local/bin/gitea web --config /etc/gitea/app.ini
Restart=always
Environment=USER=git HOME=/home/git GITEA_WORK_DIR=/var/lib/gitea
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable gitea
systemctl start gitea
```
### Web-Setup abschließen
1. Browser öffnen: `http://192.168.2.40:3000`
2. Initial-Setup:
- **Database:** SQLite3
- **SSH Port:** 22
- **HTTP Port:** 3000
- **Base URL:** `http://192.168.2.40:3000/` (später ändern für Domain)
- **Admin Account erstellen**
### Berechtigungen nach Setup fixieren
```bash
chmod 750 /etc/gitea
chmod 640 /etc/gitea/app.ini
```
## Teil 3: Gitea Actions aktivieren
### app.ini anpassen
```bash
nano /etc/gitea/app.ini
```
Folgende Sektion hinzufügen/anpassen:
```ini
[actions]
ENABLED = true
DEFAULT_ACTIONS_URL = github
```
Gitea neustarten:
```bash
systemctl restart gitea
```
## Teil 4: Gitea Actions Runner
### Runner installieren
```bash
# Als root auf dem Gitea-Server (oder separater Server)
cd /opt
RUNNER_VERSION="0.2.6"
wget https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64
mv act_runner-${RUNNER_VERSION}-linux-amd64 act_runner
chmod +x act_runner
```
### Runner Token generieren
1. Gitea Web UI → `Site Administration → Actions → Runners`
2. `Create new Runner` → Token kopieren
### Runner registrieren
```bash
cd /opt
./act_runner register --no-interactive \
--instance http://192.168.2.40:3000 \
--token <DEIN_TOKEN> \
--name homelab-runner \
--labels ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://ubuntu:22.04
```
### Runner als Systemd Service
```bash
cat > /etc/systemd/system/gitea-runner.service << 'EOF'
[Unit]
Description=Gitea Actions Runner
After=network.target gitea.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt
ExecStart=/opt/act_runner daemon
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable gitea-runner
systemctl start gitea-runner
```
### Docker für Runner installieren (falls Labels mit docker:// genutzt)
```bash
apt install -y docker.io
systemctl enable docker
systemctl start docker
```
## Teil 5: SSH Deploy Key einrichten
### Auf dem Gitea-Server (Runner)
```bash
# SSH Key für Deployments erstellen
ssh-keygen -t ed25519 -C "gitea-deploy" -f /root/.ssh/deploy_key -N ""
# Public Key anzeigen
cat /root/.ssh/deploy_key.pub
```
### Auf dem GSM-Server (192.168.2.30)
```bash
# Public Key zu authorized_keys hinzufügen
echo "ssh-ed25519 AAAA... gitea-deploy" >> /root/.ssh/authorized_keys
```
### In Gitea als Secret speichern
1. Repository → `Settings → Actions → Secrets`
2. Neues Secret: `SSH_DEPLOY_KEY`
3. Inhalt: Private Key (`cat /root/.ssh/deploy_key`)
## Teil 6: GSM Repository einrichten
### Auf dem GSM-Server (192.168.2.30)
```bash
cd /opt/gameserver-monitor
# Falls noch kein Git-Repo
git init
git add .
git commit -m "Initial commit"
# Gitea als Remote hinzufügen
git remote add origin http://192.168.2.40:3000/<user>/gameserver-monitor.git
git push -u origin main
```
### Workflow-Datei erstellen
Im Repository `.gitea/workflows/deploy.yml` erstellen:
```yaml
name: Deploy GSM
on:
push:
branches: [main]
workflow_dispatch: # Manueller Trigger
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to Server
uses: appleboy/ssh-action@v1.0.3
with:
host: 192.168.2.30
username: root
key: ${{ secrets.SSH_DEPLOY_KEY }}
script: |
set -e
cd /opt/gameserver-monitor
echo "=== Pulling latest changes ==="
git fetch origin main
git reset --hard origin/main
echo "=== Installing backend dependencies ==="
cd backend
npm ci --production
echo "=== Building frontend ==="
cd ../frontend
npm ci
npm run build
echo "=== Restarting services ==="
pm2 restart gsm-backend
echo "=== Deploy complete ==="
```
### Alternative: Separater Frontend/Backend Deploy
```yaml
name: Deploy GSM
on:
push:
branches: [main]
paths:
- 'backend/**'
- 'frontend/**'
jobs:
deploy-backend:
runs-on: ubuntu-latest
if: contains(github.event.head_commit.modified, 'backend/')
steps:
- uses: actions/checkout@v4
- name: Deploy Backend
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/backend
git pull origin main
npm ci --production
pm2 restart gsm-backend
deploy-frontend:
runs-on: ubuntu-latest
if: contains(github.event.head_commit.modified, 'frontend/')
steps:
- uses: actions/checkout@v4
- name: Deploy Frontend
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/frontend
git pull origin main
npm ci
npm run build
```
## Teil 7: Reverse Proxy (Optional)
### Nginx Proxy Manager Konfiguration
Falls Gitea über Domain erreichbar sein soll (z.B. `git.zeasy.dev`):
1. Nginx Proxy Manager → Proxy Hosts → Add
2. **Domain:** git.zeasy.dev
3. **Forward Hostname:** 192.168.2.40
4. **Forward Port:** 3000
5. **SSL:** Let's Encrypt aktivieren
### Gitea app.ini anpassen
```ini
[server]
DOMAIN = git.zeasy.dev
ROOT_URL = https://git.zeasy.dev/
SSH_DOMAIN = git.zeasy.dev
```
## Teil 8: Lokale Git-Konfiguration
### Remote für lokales Entwickeln
```bash
# Im lokalen Projekt
cd E:/Projects/homelab-docs/gsm-frontend
git remote add gitea http://192.168.2.40:3000/<user>/gameserver-monitor.git
# Oder mit SSH (wenn SSH-Key eingerichtet)
git remote add gitea git@192.168.2.40:<user>/gameserver-monitor.git
```
### Workflow
```bash
# Entwickeln...
git add .
git commit -m "Feature: xyz"
git push gitea main # → Triggert automatisch Deploy
```
## Troubleshooting
### Runner-Status prüfen
```bash
systemctl status gitea-runner
journalctl -u gitea-runner -f
```
### Gitea Logs
```bash
journalctl -u gitea -f
# oder
tail -f /var/lib/gitea/log/gitea.log
```
### Actions Debug
- In Gitea Web UI: Repository → Actions → Job auswählen → Logs ansehen
### SSH-Verbindung testen
```bash
# Vom Runner aus
ssh -i /root/.ssh/deploy_key root@192.168.2.30 "echo 'Connection OK'"
```
## Ressourcen
- Gitea Docs: https://docs.gitea.io/
- Gitea Actions: https://docs.gitea.io/en-us/actions/overview/
- Act Runner: https://gitea.com/gitea/act_runner

95
gsm-backend/config.json Normal file
View File

@@ -0,0 +1,95 @@
{
"ramBudget": 30,
"servers": [
{
"id": "minecraft",
"name": "All the Mods 10 | Minecraft",
"host": "192.168.2.51",
"type": "minecraft",
"runtime": "screen",
"maxRam": 12,
"rconPort": 25575,
"rconPassword": "gsm-mc-2026",
"screenName": "minecraft",
"workDir": "/opt/minecraft",
"startCmd": "./run.sh"
},
{
"id": "factorio",
"name": "Factorio",
"host": "192.168.2.50",
"type": "factorio",
"runtime": "docker",
"maxRam": 4,
"containerName": "factorio",
"rconPort": 27015,
"rconPassword": "jieTig6IkixaKuu"
},
{
"id": "vrising",
"name": "V Rising",
"host": "192.168.2.52",
"type": "vrising",
"runtime": "systemd",
"maxRam": 12,
"serviceName": "vrising",
"rconPort": 25575,
"rconPassword": "changeme",
"workDir": "/home/steam/vrising"
},
{
"id": "zomboid",
"name": "Project Zomboid",
"host": "10.0.30.66",
"type": "zomboid",
"runtime": "screen",
"external": true,
"rconPort": 27015,
"rconPassword": "ShkeloAufNettoParkplatzSchlagen47139",
"screenName": "zomboid",
"workDir": "/opt/pzserver",
"startCmd": "./start-server.sh -servername Project",
"sshUser": "pzuser",
"logFile": "/home/pzuser/Zomboid/server-console.txt"
},
{
"id": "palworld",
"name": "Palworld",
"host": "192.168.2.53",
"type": "palworld",
"runtime": "systemd",
"maxRam": 12,
"serviceName": "palworld",
"rconPort": 25575,
"rconPassword": "gsm-pal-admin-2026",
"restApiPort": 8212,
"workDir": "/opt/palworld",
"configPath": "/opt/palworld/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini"
},
{
"id": "terraria",
"name": "Terraria",
"host": "10.0.30.202",
"type": "terraria",
"runtime": "pm2",
"external": true,
"serviceName": "terraria",
"sshUser": "terraria",
"workDir": "/home/terraria/1449/Linux",
"configPath": "/home/terraria/serverconfig.txt",
"port": 7777
},
{
"id": "openttd",
"name": "OpenTTD",
"host": "10.0.30.203",
"type": "openttd",
"runtime": "systemd",
"external": true,
"serviceName": "openttd",
"sshUser": "openttd",
"workDir": "/opt/openttd",
"port": 3979
}
]
}

0
gsm-backend/config.tmp Normal file
View File

View File

294
gsm-backend/db/init.js Normal file
View File

@@ -0,0 +1,294 @@
import Database from 'better-sqlite3';
import bcrypt from 'bcrypt';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const db = new Database(join(__dirname, 'users.sqlite'));
const VALID_ROLES = ['user', 'moderator', 'superadmin'];
export function initDb() {
// Create users table with role
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'moderator', 'superadmin')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Migration: add role column if it doesn't exist
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasRole = columns.some(col => col.name === 'role');
if (!hasRole) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'");
// Upgrade existing admin user to superadmin
db.prepare("UPDATE users SET role = 'superadmin' WHERE username = 'admin'").run();
console.log('Migration: Added role column, admin upgraded to superadmin');
}
// Create default admin if no users exist
const count = db.prepare('SELECT COUNT(*) as count FROM users').get();
if (count.count === 0) {
const hash = bcrypt.hashSync('admin', 10);
db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('admin', hash, 'superadmin');
console.log('Default superadmin user created (username: admin, password: admin)');
}
}
export { db, VALID_ROLES };
// Whitelist cache table
export function initWhitelistCache() {
db.exec(`
CREATE TABLE IF NOT EXISTS whitelist_cache (
server_id TEXT PRIMARY KEY,
players TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
export function getCachedWhitelist(serverId) {
const row = db.prepare('SELECT players FROM whitelist_cache WHERE server_id = ?').get(serverId);
return row ? JSON.parse(row.players) : [];
}
export function setCachedWhitelist(serverId, players) {
db.prepare(`
INSERT OR REPLACE INTO whitelist_cache (server_id, players, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
`).run(serverId, JSON.stringify(players));
}
// Factorio templates table
export function initFactorioTemplates() {
db.exec(`
CREATE TABLE IF NOT EXISTS factorio_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
settings TEXT NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
}
export function getFactorioTemplates() {
return db.prepare(`
SELECT t.id, t.name, t.settings, t.created_at, u.username as created_by_name
FROM factorio_templates t
LEFT JOIN users u ON t.created_by = u.id
ORDER BY t.name
`).all();
}
export function getFactorioTemplate(id) {
return db.prepare("SELECT * FROM factorio_templates WHERE id = ?").get(id);
}
export function createFactorioTemplate(name, settings, userId) {
const result = db.prepare(
"INSERT INTO factorio_templates (name, settings, created_by) VALUES (?, ?, ?)"
).run(name, JSON.stringify(settings), userId);
return result.lastInsertRowid;
}
export function deleteFactorioTemplate(id) {
return db.prepare("DELETE FROM factorio_templates WHERE id = ?").run(id);
}
// Factorio world settings table (stores settings used when creating worlds)
export function initFactorioWorldSettings() {
db.exec(`
CREATE TABLE IF NOT EXISTS factorio_world_settings (
save_name TEXT PRIMARY KEY,
settings TEXT NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
}
export function getFactorioWorldSettings(saveName) {
return db.prepare(
"SELECT ws.*, u.username as created_by_name FROM factorio_world_settings ws LEFT JOIN users u ON ws.created_by = u.id WHERE ws.save_name = ?"
).get(saveName);
}
export function saveFactorioWorldSettings(saveName, settings, userId) {
return db.prepare(
"INSERT OR REPLACE INTO factorio_world_settings (save_name, settings, created_by, created_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)"
).run(saveName, JSON.stringify(settings), userId);
}
export function deleteFactorioWorldSettings(saveName) {
return db.prepare("DELETE FROM factorio_world_settings WHERE save_name = ?").run(saveName);
}
// Auto-shutdown settings table
export function initAutoShutdownSettings() {
db.exec(`
CREATE TABLE IF NOT EXISTS autoshutdown_settings (
server_id TEXT PRIMARY KEY,
enabled INTEGER DEFAULT 0,
timeout_minutes INTEGER DEFAULT 15,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
export function getAutoShutdownSettings(serverId) {
return db.prepare('SELECT * FROM autoshutdown_settings WHERE server_id = ?').get(serverId);
}
export function getAllAutoShutdownSettings() {
return db.prepare('SELECT * FROM autoshutdown_settings WHERE enabled = 1').all();
}
export function setAutoShutdownSettings(serverId, enabled, timeoutMinutes) {
return db.prepare(`
INSERT OR REPLACE INTO autoshutdown_settings (server_id, enabled, timeout_minutes, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`).run(serverId, enabled ? 1 : 0, timeoutMinutes);
}
// Discord users table
export function initDiscordUsers() {
db.exec(`
CREATE TABLE IF NOT EXISTS discord_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_id TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
discriminator TEXT DEFAULT '0',
avatar TEXT,
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'moderator', 'superadmin')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
// Activity Log
export function initActivityLog() {
db.exec(`
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username TEXT NOT NULL,
discord_id TEXT,
avatar TEXT,
action TEXT NOT NULL,
target TEXT,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
export function logActivity(userId, username, action, target = null, details = null, discordId = null, avatar = null) {
db.prepare(`
INSERT INTO activity_log (user_id, username, discord_id, avatar, action, target, details)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(userId, username, discordId, avatar, action, target, details);
}
export function getActivityLog(limit = 100) {
return db.prepare(`
SELECT * FROM activity_log ORDER BY created_at DESC LIMIT ?
`).all(limit);
}
// Server display settings table
export function initServerDisplaySettings() {
db.exec(`
CREATE TABLE IF NOT EXISTS server_display_settings (
server_id TEXT PRIMARY KEY,
address TEXT,
hint TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
export function getServerDisplaySettings(serverId) {
return db.prepare('SELECT * FROM server_display_settings WHERE server_id = ?').get(serverId);
}
export function getAllServerDisplaySettings() {
return db.prepare('SELECT * FROM server_display_settings').all();
}
export function setServerDisplaySettings(serverId, address, hint) {
return db.prepare(`
INSERT OR REPLACE INTO server_display_settings (server_id, address, hint, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`).run(serverId, address, hint);
}
// Guild settings for multi-server Discord bot
export function initGuildSettings() {
db.exec(`
CREATE TABLE IF NOT EXISTS guild_settings (
guild_id TEXT PRIMARY KEY,
category_id TEXT,
info_channel_id TEXT,
status_channel_id TEXT,
status_message_id TEXT,
alerts_channel_id TEXT,
updates_channel_id TEXT,
discussion_channel_id TEXT,
requests_channel_id TEXT,
requests_info_thread_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
export function getGuildSettings(guildId) {
return db.prepare('SELECT * FROM guild_settings WHERE guild_id = ?').get(guildId);
}
export function getAllGuildSettings() {
return db.prepare('SELECT * FROM guild_settings').all();
}
export function setGuildSettings(guildId, settings) {
const existing = getGuildSettings(guildId);
if (existing) {
return db.prepare(`
UPDATE guild_settings SET
category_id = ?, info_channel_id = ?, status_channel_id = ?, status_message_id = ?,
alerts_channel_id = ?, updates_channel_id = ?, discussion_channel_id = ?,
requests_channel_id = ?, requests_info_thread_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE guild_id = ?
`).run(
settings.category_id, settings.info_channel_id, settings.status_channel_id,
settings.status_message_id, settings.alerts_channel_id, settings.updates_channel_id,
settings.discussion_channel_id, settings.requests_channel_id, settings.requests_info_thread_id,
guildId
);
} else {
return db.prepare(`
INSERT INTO guild_settings (guild_id, category_id, info_channel_id, status_channel_id,
status_message_id, alerts_channel_id, updates_channel_id, discussion_channel_id,
requests_channel_id, requests_info_thread_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
guildId, settings.category_id, settings.info_channel_id, settings.status_channel_id,
settings.status_message_id, settings.alerts_channel_id, settings.updates_channel_id,
settings.discussion_channel_id, settings.requests_channel_id, settings.requests_info_thread_id
);
}
}
export function deleteGuildSettings(guildId) {
return db.prepare('DELETE FROM guild_settings WHERE guild_id = ?').run(guildId);
}

BIN
gsm-backend/db/users.sqlite Normal file

Binary file not shown.

View File

@@ -0,0 +1,57 @@
import jwt from 'jsonwebtoken';
const ROLE_HIERARCHY = {
'user': 1,
'moderator': 2,
'superadmin': 3
};
export function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Token required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid token' });
}
req.user = user;
next();
});
}
// Optional authentication - doesn't fail if no token
export function optionalAuth(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
req.user = null;
return next();
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
req.user = null;
} else {
req.user = user;
}
next();
});
}
export function requireRole(minRole) {
return (req, res, next) => {
const userRole = req.user?.role || 'user';
const userLevel = ROLE_HIERARCHY[userRole] || 0;
const requiredLevel = ROLE_HIERARCHY[minRole] || 0;
if (userLevel < requiredLevel) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}

2461
gsm-backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
gsm-backend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "gameserver-monitor-backend",
"version": "1.0.0",
"type": "module",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^11.0.0",
"cors": "^2.8.5",
"discord.js": "^14.25.1",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2",
"node-ssh": "^13.2.0",
"rcon-client": "^4.2.4"
}
}

244
gsm-backend/routes/auth.js Normal file
View File

@@ -0,0 +1,244 @@
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

@@ -3,10 +3,10 @@ import { readFileSync } from 'fs';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js'; import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js';
import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig } from '../services/ssh.js'; import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig, readTerrariaConfig, writeTerrariaConfig, readOpenTTDConfig, writeOpenTTDConfig } from '../services/ssh.js';
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js'; import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js'; import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js';
import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings } from '../db/init.js'; import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings, initGuildSettings } from '../db/init.js';
import { getEmptySince } from '../services/autoshutdown.js'; import { getEmptySince } from '../services/autoshutdown.js';
import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js'; import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js';
@@ -15,6 +15,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
function loadConfig() { function loadConfig() {
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8')); return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
} }
// RAM Budget Checkasync function checkRamBudget(serverToStart) { const config = loadConfig(); const ramBudget = config.ramBudget || 30; let usedRam = 0; for (const server of config.servers) { if (server.id === serverToStart.id) continue; try { const status = await getServerStatus(server); if (status === "online") usedRam += server.maxRam || 0; } catch (err) {} } const serverRam = serverToStart.maxRam || 0; const availableRam = ramBudget - usedRam; return { canStart: availableRam >= serverRam, usedRam, serverRam, availableRam, ramBudget };}
// Initialize tables // Initialize tables
initWhitelistCache(); initWhitelistCache();
@@ -22,6 +23,7 @@ initActivityLog();
initFactorioTemplates(); initFactorioTemplates();
initFactorioWorldSettings(); initFactorioWorldSettings();
initServerDisplaySettings(); initServerDisplaySettings();
initGuildSettings();
const router = Router(); const router = Router();
@@ -409,6 +411,73 @@ router.get("/display-settings", optionalAuth, async (req, res) => {
} }
}); });
// ============ TERRARIA ROUTES ============
// Get Terraria config
router.get("/terraria/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "terraria");
if (!server) return res.status(404).json({ error: "Server not found" });
const content = await readTerrariaConfig(server);
res.json({ content });
} catch (error) {
console.error("Error reading Terraria config:", error);
res.status(500).json({ error: error.message });
}
});
// Save Terraria config
router.put("/terraria/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "terraria");
if (!server) return res.status(404).json({ error: "Server not found" });
const { content } = req.body;
if (!content) return res.status(400).json({ error: "Content required" });
await writeTerrariaConfig(server, content);
logActivity(req.user.id, req.user.username, "terraria_config", "terraria", "serverconfig.txt", req.user.discordId, req.user.avatar);
res.json({ success: true });
} catch (error) {
console.error("Error writing Terraria config:", error);
res.status(500).json({ error: error.message });
}
});
// ============ OPENTTD ROUTES ============
// Get OpenTTD config
router.get("/openttd/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "openttd");
if (!server) return res.status(404).json({ error: "Server not found" });
const content = await readOpenTTDConfig(server);
res.json({ content });
} catch (error) {
console.error("Error reading OpenTTD config:", error);
res.status(500).json({ error: error.message });
}
});
// Save OpenTTD config
router.put("/openttd/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.id === "openttd");
if (!server) return res.status(404).json({ error: "Server not found" });
const { content } = req.body;
if (!content) return res.status(400).json({ error: "Content required" });
await writeOpenTTDConfig(server, content);
logActivity(req.user.id, req.user.username, "openttd_config", "openttd", "openttd.cfg", req.user.discordId, req.user.avatar);
res.json({ success: true });
} catch (error) {
console.error("Error writing OpenTTD config:", error);
res.status(500).json({ error: error.message });
}
});
// Get single server // Get single server
router.get('/:id', optionalAuth, async (req, res) => { router.get('/:id', optionalAuth, async (req, res) => {
const config = loadConfig(); const config = loadConfig();

View File

@@ -0,0 +1,679 @@
import { Router } from 'express';
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { authenticateToken, optionalAuth, requireRole } from '../middleware/auth.js';
import { getServerStatus, startServer, stopServer, restartServer, getConsoleLog, getProcessUptime, listFactorioSaves, createFactorioWorld, deleteFactorioSave, getFactorioCurrentSave, isHostFailed, listZomboidConfigs, readZomboidConfig, writeZomboidConfig, listPalworldConfigs, readPalworldConfig, writePalworldConfig } from '../services/ssh.js';
import { sendRconCommand, getPlayers, getPlayerList } from '../services/rcon.js';
import { getServerMetricsHistory, getCurrentMetrics } from '../services/prometheus.js';
import { initWhitelistCache, getCachedWhitelist, setCachedWhitelist, initFactorioTemplates, getFactorioTemplates, createFactorioTemplate, deleteFactorioTemplate, initFactorioWorldSettings, getFactorioWorldSettings, saveFactorioWorldSettings, deleteFactorioWorldSettings, initAutoShutdownSettings, getAutoShutdownSettings, setAutoShutdownSettings, initActivityLog, logActivity, getActivityLog, initServerDisplaySettings, getServerDisplaySettings, getAllServerDisplaySettings, setServerDisplaySettings, initGuildSettings } from '../db/init.js';
import { getEmptySince } from '../services/autoshutdown.js';
import { getDefaultMapGenSettings, getPresetNames, getPreset } from '../services/factorio.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadConfig() {
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
}
// RAM Budget Checkasync function checkRamBudget(serverToStart) { const config = loadConfig(); const ramBudget = config.ramBudget || 30; let usedRam = 0; for (const server of config.servers) { if (server.id === serverToStart.id) continue; try { const status = await getServerStatus(server); if (status === "online") usedRam += server.maxRam || 0; } catch (err) {} } const serverRam = serverToStart.maxRam || 0; const availableRam = ramBudget - usedRam; return { canStart: availableRam >= serverRam, usedRam, serverRam, availableRam, ramBudget };}
// Initialize tables
initWhitelistCache();
initActivityLog();
initFactorioTemplates();
initFactorioWorldSettings();
initServerDisplaySettings();
initGuildSettings();
const router = Router();
function formatBytes(bytes, forceUnit = null) {
if (bytes === 0) return { value: 0, unit: forceUnit || "B" };
const gb = bytes / (1024 * 1024 * 1024);
const mb = bytes / (1024 * 1024);
if (forceUnit === "GB") return { value: gb, unit: "GB" };
if (forceUnit === "MB") return { value: mb, unit: "MB" };
if (gb >= 1) return { value: gb, unit: "GB" };
return { value: mb, unit: "MB" };
}
// ============ FACTORIO ROUTES ============
// Factorio: List saves
router.get("/factorio/saves", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "factorio");
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
const saves = await listFactorioSaves(server);
res.json({ saves });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Get presets and default settings
router.get("/factorio/presets", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const presets = getPresetNames();
const defaultSettings = getDefaultMapGenSettings();
res.json({ presets, defaultSettings });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Get preset by name
router.get("/factorio/presets/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const preset = getPreset(req.params.name);
res.json({ settings: preset });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: List templates
router.get("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const templates = getFactorioTemplates();
res.json({ templates: templates.map(t => ({ ...t, settings: JSON.parse(t.settings) })) });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Create template
router.post("/factorio/templates", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const { name, settings } = req.body;
if (!name || !settings) {
return res.status(400).json({ error: "Name and settings required" });
}
const id = createFactorioTemplate(name, settings, req.user.id);
res.json({ id, message: "Template created" });
} catch (err) {
if (err.message.includes("UNIQUE constraint")) {
return res.status(400).json({ error: "Template name already exists" });
}
res.status(500).json({ error: err.message });
}
});
// Factorio: Delete template
router.delete("/factorio/templates/:id", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const result = deleteFactorioTemplate(parseInt(req.params.id));
if (result.changes === 0) {
return res.status(404).json({ error: "Template not found" });
}
res.json({ message: "Template deleted" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Create new world
router.post("/factorio/create-world", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const { saveName, settings } = req.body;
if (!saveName) {
return res.status(400).json({ error: "Save name required" });
}
const config = loadConfig();
const server = config.servers.find(s => s.type === "factorio");
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
const finalSettings = settings || getDefaultMapGenSettings();
await createFactorioWorld(server, saveName, finalSettings);
// Save settings to database for later reference
saveFactorioWorldSettings(saveName, finalSettings, req.user.id);
logActivity(req.user.id, req.user.username, 'factorio_world_create', 'factorio', saveName, req.user.discordId, req.user.avatar);
res.json({ message: "World created", saveName });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Delete save
router.delete("/factorio/saves/:name", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "factorio");
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
await deleteFactorioSave(server, req.params.name);
// Also delete stored settings if they exist
deleteFactorioWorldSettings(req.params.name);
logActivity(req.user.id, req.user.username, 'factorio_world_delete', 'factorio', req.params.name, req.user.discordId, req.user.avatar);
res.json({ message: "Save deleted" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Get world settings
router.get("/factorio/saves/:name/settings", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const settings = getFactorioWorldSettings(req.params.name);
if (!settings) {
return res.json({
legacy: true,
message: "This is a legacy world created before settings tracking was implemented"
});
}
res.json({
legacy: false,
settings: JSON.parse(settings.settings),
createdBy: settings.created_by,
createdAt: settings.created_at
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Factorio: Get current/default save
router.get("/factorio/current-save", authenticateToken, async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "factorio");
if (!server) return res.status(404).json({ error: "Factorio server not configured" });
const result = await getFactorioCurrentSave(server);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ============ ZOMBOID CONFIG ROUTES ============
// Zomboid: List config files
router.get("/zomboid/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "zomboid");
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
const files = await listZomboidConfigs(server);
res.json({ files });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Zomboid: Read config file
router.get("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "zomboid");
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
const content = await readZomboidConfig(server, req.params.filename);
res.json({ filename: req.params.filename, content });
} catch (err) {
if (err.message === "File not allowed") {
return res.status(403).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
// Zomboid: Write config file
router.put("/zomboid/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "zomboid");
if (!server) return res.status(404).json({ error: "Zomboid server not configured" });
const { content } = req.body;
if (content === undefined) {
return res.status(400).json({ error: "Content required" });
}
await writeZomboidConfig(server, req.params.filename, content);
logActivity(req.user.id, req.user.username, 'zomboid_config', 'zomboid', req.params.filename, req.user.discordId, req.user.avatar);
res.json({ message: "Config saved", filename: req.params.filename });
} catch (err) {
if (err.message === "File not allowed") {
return res.status(403).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
// Palworld: List config files
router.get("/palworld/config", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "palworld");
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
const files = await listPalworldConfigs(server);
res.json({ files });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Palworld: Read config file
router.get("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "palworld");
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
const content = await readPalworldConfig(server, req.params.filename);
res.json({ filename: req.params.filename, content });
} catch (err) {
if (err.message === "File not allowed") {
return res.status(403).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
// Palworld: Write config file
router.put("/palworld/config/:filename", authenticateToken, requireRole("moderator"), async (req, res) => {
try {
const config = loadConfig();
const server = config.servers.find(s => s.type === "palworld");
if (!server) return res.status(404).json({ error: "Palworld server not configured" });
const { content } = req.body;
if (content === undefined) {
return res.status(400).json({ error: "Content required" });
}
await writePalworldConfig(server, req.params.filename, content);
logActivity(req.user.id, req.user.username, "palworld_config", "palworld", req.params.filename, req.user.discordId, req.user.avatar);
res.json({ message: "Config saved", filename: req.params.filename });
} catch (err) {
if (err.message === "File not allowed") {
return res.status(403).json({ error: err.message });
}
res.status(500).json({ error: err.message });
}
});
// ============ GENERAL ROUTES ============
// Get all servers with status
router.get('/', optionalAuth, async (req, res) => {
try {
const config = loadConfig();
const servers = await Promise.all(config.servers.map(async (server) => {
// Quick check if host is unreachable - skip expensive operations
const hostUnreachable = isHostFailed(server.host, server.sshUser);
// If host is unreachable, return immediately with minimal data
if (hostUnreachable) {
const metrics = await getCurrentMetrics(server.id).catch(() => ({
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
}));
const memTotal = formatBytes(metrics.memoryTotal);
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
return {
id: server.id,
name: server.name,
type: server.type,
status: "unreachable",
running: false,
metrics: {
cpu: metrics.cpu,
cpuCores: metrics.cpuCores,
memory: metrics.memory,
memoryUsed: memUsed.value,
memoryTotal: memTotal.value,
memoryUnit: memTotal.unit,
uptime: 0
},
players: { online: 0, max: null, list: [] },
hasRcon: !!server.rconPassword
};
}
const [status, metrics, players, playerList, processUptime] = await Promise.all([
getServerStatus(server),
getCurrentMetrics(server.id).catch(() => ({
cpu: 0, cpuCores: 1, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0
})),
server.rconPassword ? getPlayers(server).catch(() => ({ online: 0, max: null })) : { online: 0, max: null },
server.rconPassword ? getPlayerList(server).catch(() => ({ players: [] })) : { players: [] },
getProcessUptime(server).catch(() => 0)
]);
const memTotal = formatBytes(metrics.memoryTotal);
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
// Get auto-shutdown info
const shutdownSettings = getAutoShutdownSettings(server.id);
const emptySince = getEmptySince(server.id);
return {
id: server.id,
name: server.name,
type: server.type,
status,
running: status === 'online',
metrics: {
cpu: metrics.cpu,
cpuCores: metrics.cpuCores,
memory: metrics.memory,
memoryUsed: memUsed.value,
memoryTotal: memTotal.value,
memoryUnit: memTotal.unit,
uptime: processUptime
},
players: {
...players,
list: playerList.players
},
hasRcon: !!server.rconPassword,
autoShutdown: {
enabled: shutdownSettings?.enabled === 1 || false,
timeoutMinutes: shutdownSettings?.timeout_minutes || 15,
emptySinceMinutes: emptySince
}
};
}));
res.json(servers);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Activity Log (superadmin only)
router.get('/activity-log', authenticateToken, requireRole('superadmin'), (req, res) => {
try {
const limit = parseInt(req.query.limit) || 100;
const logs = getActivityLog(limit);
res.json(logs);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get all server display settings (for ServerCard)
router.get("/display-settings", optionalAuth, async (req, res) => {
try {
const settings = getAllServerDisplaySettings();
const result = {};
settings.forEach(s => {
result[s.server_id] = { address: s.address, hint: s.hint };
});
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get single server
router.get('/:id', optionalAuth, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) {
return res.status(404).json({ error: 'Server not found' });
}
try {
const [status, metrics, players, playerList, processUptime] = await Promise.all([
getServerStatus(server),
getCurrentMetrics(server.id),
server.rconPassword ? getPlayers(server) : { online: 0, max: null },
server.rconPassword ? getPlayerList(server) : { players: [] },
getProcessUptime(server).catch(() => 0)
]);
const memTotal = formatBytes(metrics.memoryTotal);
const memUsed = formatBytes(metrics.memoryUsed, memTotal.unit);
res.json({
id: server.id,
name: server.name,
type: server.type,
status,
running: status === 'online',
metrics: {
cpu: metrics.cpu,
cpuCores: metrics.cpuCores,
memory: metrics.memory,
memoryUsed: memUsed.value,
memoryTotal: memTotal.value,
memoryUnit: memTotal.unit,
uptime: processUptime
},
players: {
...players,
list: playerList.players
},
hasRcon: !!server.rconPassword
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get metrics history from Prometheus
router.get('/:id/metrics/history', optionalAuth, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
const range = req.query.range || '1h';
const validRanges = ['15m', '1h', '6h', '24h'];
if (!validRanges.includes(range)) {
return res.status(400).json({ error: 'Invalid range. Valid: 15m, 1h, 6h, 24h' });
}
try {
const history = await getServerMetricsHistory(server.id, range);
res.json(history);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get player list
router.get('/:id/players', authenticateToken, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (!server.rconPassword) {
return res.status(400).json({ error: 'RCON not configured for this server' });
}
try {
const [count, list] = await Promise.all([
getPlayers(server),
getPlayerList(server)
]);
res.json({ ...count, list: list.players });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get console logs (moderator+)
router.get('/:id/logs', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
try {
const lines = parseInt(req.query.lines) || 100;
const logs = await getConsoleLog(server, lines);
res.json({ logs });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Power actions (moderator+)
router.post('/:id/start', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
try {
const { save } = req.body || {};
await startServer(server, { save });
logActivity(req.user.id, req.user.username, 'server_start', server.id, save ? 'Save: ' + save : null, req.user.discordId, req.user.avatar);
res.json({ message: 'Server starting' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/:id/stop', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
try {
await stopServer(server);
logActivity(req.user.id, req.user.username, 'server_stop', server.id, null, req.user.discordId, req.user.avatar);
res.json({ message: 'Server stopping' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/:id/restart', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
try {
await restartServer(server);
logActivity(req.user.id, req.user.username, 'server_restart', server.id, null, req.user.discordId, req.user.avatar);
res.json({ message: 'Server restarting' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get whitelist (with server-side caching)
router.get('/:id/whitelist', optionalAuth, async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (!server.rconPassword) {
return res.json({ players: [], cached: false });
}
try {
const response = await sendRconCommand(server, 'whitelist list');
const match = response.trim().match(/:\s*(.+)$/);
let players = [];
if (match && match[1]) {
players = match[1].split(',').map(p => p.trim()).filter(p => p.length > 0);
}
setCachedWhitelist(server.id, players);
res.json({ players, cached: false });
} catch (err) {
const players = getCachedWhitelist(server.id);
res.json({ players, cached: true });
}
});
// RCON command (moderator+)
router.post('/:id/rcon', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
if (!server.rconPassword) {
return res.status(400).json({ error: 'RCON not configured for this server' });
}
const { command } = req.body;
if (!command) {
return res.status(400).json({ error: 'Command required' });
}
try {
const response = await sendRconCommand(server, command);
logActivity(req.user.id, req.user.username, 'rcon_command', server.id, command, req.user.discordId, req.user.avatar);
if (command.startsWith("whitelist ")) {
try {
const listResponse = await sendRconCommand(server, "whitelist list");
const match = listResponse.trim().match(/:\s*(.+)$/);
let players = [];
if (match && match[1]) {
players = match[1].split(",").map(p => p.trim()).filter(p => p.length > 0);
}
setCachedWhitelist(server.id, players);
} catch (e) { /* ignore */ }
}
res.json({ response });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Initialize auto-shutdown settings table
initAutoShutdownSettings();
// ============ AUTO-SHUTDOWN ROUTES ============
// Get auto-shutdown settings for a server
router.get('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
const settings = getAutoShutdownSettings(req.params.id);
const emptySince = getEmptySince(req.params.id);
res.json({
enabled: settings?.enabled === 1 || false,
timeoutMinutes: settings?.timeout_minutes || 15,
emptySinceMinutes: emptySince
});
});
// Update auto-shutdown settings for a server
router.put('/:id/autoshutdown', authenticateToken, requireRole('moderator'), async (req, res) => {
const config = loadConfig();
const server = config.servers.find(s => s.id === req.params.id);
if (!server) return res.status(404).json({ error: 'Server not found' });
const { enabled, timeoutMinutes } = req.body;
const timeout = Math.max(1, Math.min(1440, timeoutMinutes || 15));
setAutoShutdownSettings(req.params.id, enabled, timeout);
logActivity(req.user.id, req.user.username, 'autoshutdown_config', req.params.id, 'Enabled: ' + enabled + ', Timeout: ' + timeout + ' min', req.user.discordId, req.user.avatar);
console.log('[AutoShutdown] Settings updated for ' + req.params.id + ': enabled=' + enabled + ', timeout=' + timeout + 'min');
res.json({ message: 'Auto-shutdown settings updated', enabled, timeoutMinutes: timeout });
});
// Get display settings for a specific server
router.get("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
try {
const settings = getServerDisplaySettings(req.params.id);
res.json(settings || { server_id: req.params.id, address: "", hint: "" });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Update display settings for a server (superadmin only)
router.put("/:id/display-settings", authenticateToken, requireRole("superadmin"), async (req, res) => {
const { address, hint } = req.body;
try {
setServerDisplaySettings(req.params.id, address || "", hint || "");
res.json({ message: "Display settings updated", address, hint });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
export default router;

37
gsm-backend/server.js Normal file
View File

@@ -0,0 +1,37 @@
import express from 'express';
import cors from 'cors';
import { config } from 'dotenv';
import authRoutes from './routes/auth.js';
import serverRoutes from './routes/servers.js';
import { initDb } from './db/init.js';
import { startAutoShutdownService } from './services/autoshutdown.js';
import { initDiscordBot } from './services/discordBot.js';
config();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
// Initialize database
initDb();
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/servers', serverRoutes);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
// Start auto-shutdown service
startAutoShutdownService();
// Start Discord bot
initDiscordBot();
});

View File

@@ -0,0 +1,113 @@
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { getServerStatus, stopServer } from './ssh.js';
import { getPlayers } from './rcon.js';
import { getAllAutoShutdownSettings, getAutoShutdownSettings } from '../db/init.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Track when each server became empty
const emptyPlayersSince = new Map();
// Check interval in ms (60 seconds)
const CHECK_INTERVAL = 60000;
function loadConfig() {
return JSON.parse(readFileSync(join(__dirname, '..', 'config.json'), 'utf-8'));
}
async function checkServers() {
try {
const config = loadConfig();
const enabledSettings = getAllAutoShutdownSettings();
// Create a map for quick lookup
const settingsMap = new Map(enabledSettings.map(s => [s.server_id, s]));
for (const server of config.servers) {
const settings = settingsMap.get(server.id);
// Skip if auto-shutdown not enabled for this server
if (!settings || !settings.enabled) {
emptyPlayersSince.delete(server.id);
continue;
}
try {
// Check if server is online
const status = await getServerStatus(server);
if (status !== 'online') {
// Server not running, clear timer
emptyPlayersSince.delete(server.id);
continue;
}
// Get player count
let playerCount = 0;
if (server.rconPassword) {
const players = await getPlayers(server);
playerCount = players.online || 0;
}
if (playerCount === 0) {
// No players online
if (!emptyPlayersSince.has(server.id)) {
// Start tracking empty time
emptyPlayersSince.set(server.id, Date.now());
console.log(`[AutoShutdown] ${server.id}: Keine Spieler online, Timer gestartet`);
}
const emptyMs = Date.now() - emptyPlayersSince.get(server.id);
const emptyMinutes = emptyMs / 60000;
if (emptyMinutes >= settings.timeout_minutes) {
console.log(`[AutoShutdown] ${server.id}: Timeout erreicht (${settings.timeout_minutes} Min), stoppe Server...`);
await stopServer(server);
emptyPlayersSince.delete(server.id);
console.log(`[AutoShutdown] ${server.id}: Server gestoppt`);
}
} else {
// Players online, reset timer
if (emptyPlayersSince.has(server.id)) {
console.log(`[AutoShutdown] ${server.id}: Spieler online (${playerCount}), Timer zurückgesetzt`);
emptyPlayersSince.delete(server.id);
}
}
} catch (err) {
console.error(`[AutoShutdown] Fehler bei ${server.id}:`, err.message);
}
}
} catch (err) {
console.error('[AutoShutdown] Fehler beim Laden der Config:', err.message);
}
}
export function startAutoShutdownService() {
console.log('[AutoShutdown] Service gestartet, prüfe alle 60 Sekunden');
// Initial check after 10 seconds (give server time to start)
setTimeout(() => {
checkServers();
}, 10000);
// Then check every 60 seconds
setInterval(checkServers, CHECK_INTERVAL);
}
// Get how long a server has been empty (for status display)
export function getEmptySince(serverId) {
const since = emptyPlayersSince.get(serverId);
if (!since) return null;
return Math.floor((Date.now() - since) / 60000); // Return minutes
}
// Get all empty-since times
export function getAllEmptySince() {
const result = {};
for (const [serverId, since] of emptyPlayersSince) {
result[serverId] = Math.floor((Date.now() - since) / 60000);
}
return result;
}

View File

@@ -0,0 +1,167 @@
// Discord OAuth2 Service
const DISCORD_API = 'https://discord.com/api/v10';
// Lazy initialization - wird erst bei Verwendung geladen (nach dotenv)
let _guildConfigs = null;
function getGuildConfigs() {
if (_guildConfigs === null) {
_guildConfigs = [
{
name: 'Bacanaks',
guildId: process.env.DISCORD_GUILD_ID_1,
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_1,
modRoleId: process.env.DISCORD_MOD_ROLE_ID_1
},
{
name: 'Piccadilly',
guildId: process.env.DISCORD_GUILD_ID_2,
adminRoleId: process.env.DISCORD_ADMIN_ROLE_ID_2,
modRoleId: process.env.DISCORD_MOD_ROLE_ID_2
}
].filter(config => config.guildId);
}
return _guildConfigs;
}
export function getDiscordAuthUrl() {
const params = new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID,
redirect_uri: process.env.DISCORD_REDIRECT_URI,
response_type: 'code',
scope: 'identify guilds.members.read'
});
return `https://discord.com/oauth2/authorize?${params}`;
}
export async function exchangeCode(code) {
const response = await fetch(`${DISCORD_API}/oauth2/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID,
client_secret: process.env.DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: process.env.DISCORD_REDIRECT_URI
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to exchange code: ${error}`);
}
return response.json();
}
export async function getDiscordUser(accessToken) {
const response = await fetch(`${DISCORD_API}/users/@me`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
if (!response.ok) {
throw new Error('Failed to get Discord user');
}
return response.json();
}
// Prüft einen einzelnen Server
async function fetchGuildMember(guildId, userId) {
const response = await fetch(
`${DISCORD_API}/guilds/${guildId}/members/${userId}`,
{
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`
}
}
);
if (!response.ok) {
if (response.status === 404) {
return null;
}
throw new Error(`Failed to get guild member from ${guildId}`);
}
return response.json();
}
// Prüft alle konfigurierten Server und gibt Memberships zurück
export async function getGuildMemberships(userId) {
const configs = getGuildConfigs();
const memberships = [];
for (const config of configs) {
try {
const member = await fetchGuildMember(config.guildId, userId);
if (member) {
memberships.push({
config,
member,
roles: member.roles || []
});
}
} catch (err) {
console.error(`[Discord] Failed to check membership for guild ${config.name}:`, err.message);
}
}
return memberships.length > 0 ? memberships : null;
}
// Legacy-Funktion für Kompatibilität
export async function getGuildMember(userId) {
const memberships = await getGuildMemberships(userId);
if (!memberships || memberships.length === 0) {
return null;
}
return memberships[0].member;
}
// Rollen-Priorität: superadmin > moderator > user
const ROLE_PRIORITY = { superadmin: 3, moderator: 2, user: 1 };
// Bestimmt die höchste Rolle aus allen Server-Memberships
export function getUserRoleFromMemberships(memberships) {
if (!memberships || memberships.length === 0) {
return 'user';
}
let highestRole = 'user';
for (const { config, roles } of memberships) {
let role = 'user';
if (roles.includes(config.adminRoleId)) {
role = 'superadmin';
} else if (roles.includes(config.modRoleId)) {
role = 'moderator';
}
if (ROLE_PRIORITY[role] > ROLE_PRIORITY[highestRole]) {
highestRole = role;
}
}
return highestRole;
}
// Legacy-Funktion für Kompatibilität
export function getUserRole(memberRoles) {
const adminRoleId = process.env.DISCORD_ADMIN_ROLE_ID || process.env.DISCORD_ADMIN_ROLE_ID_1;
const modRoleId = process.env.DISCORD_MOD_ROLE_ID || process.env.DISCORD_MOD_ROLE_ID_1;
if (memberRoles.includes(adminRoleId)) {
return 'superadmin';
}
if (memberRoles.includes(modRoleId)) {
return 'moderator';
}
return 'user';
}

View File

@@ -20,7 +20,9 @@ const serverDisplay = {
factorio: { name: 'Factorio', icon: '⚙️', color: 0xF97316, address: 'factorio.zeasy.dev' }, factorio: { name: 'Factorio', icon: '⚙️', color: 0xF97316, address: 'factorio.zeasy.dev' },
zomboid: { name: 'Project Zomboid', icon: '🧟', color: 0x4ADE80, address: 'pz.zeasy.dev:16261' }, zomboid: { name: 'Project Zomboid', icon: '🧟', color: 0x4ADE80, address: 'pz.zeasy.dev:16261' },
vrising: { name: 'V Rising', icon: '🧛', color: 0xDC2626, address: 'vrising.zeasy.dev' }, vrising: { name: 'V Rising', icon: '🧛', color: 0xDC2626, address: 'vrising.zeasy.dev' },
palworld: { name: 'Palworld', icon: '🦎', color: 0x00D4AA, address: 'palworld.zeasy.dev:8211' } palworld: { name: 'Palworld', icon: '🦎', color: 0x00D4AA, address: 'palworld.zeasy.dev:8211' },
terraria: { name: 'Terraria', icon: '⚔️', color: 0x05C46B, address: 'terraria.zeasy.dev:7777' },
openttd: { name: 'OpenTTD', icon: '🚂', color: 0x1E90FF, address: 'openttd.zeasy.dev:3979' }
}; };
function loadConfig() { function loadConfig() {
@@ -497,17 +499,28 @@ export async function initDiscordBot() {
console.log('[DiscordBot] Left guild: ' + guild.name + ' (' + guild.id + ')'); console.log('[DiscordBot] Left guild: ' + guild.name + ' (' + guild.id + ')');
deleteGuildSettings(guild.id); deleteGuildSettings(guild.id);
}); });
client.once('ready', async () => { client.once('ready', async () => {
console.log('[DiscordBot] Logged in as ' + client.user.tag); console.log('[DiscordBot] Logged in as ' + client.user.tag);
// Check for guilds without settings and set them up
for (const [guildId, guild] of client.guilds.cache) {
const settings = getGuildSettings(guildId);
if (!settings) {
console.log('[DiscordBot] Setting up missing guild: ' + guild.name);
try {
await setupGuildChannels(guild);
} catch (err) {
console.error('[DiscordBot] Failed to setup missing guild ' + guild.name + ':', err);
}
}
}
// First run - populate state without alerts // First run - populate state without alerts
await updateAllStatusMessages(true); await updateAllStatusMessages(true);
// Regular updates every 60 seconds // Regular updates every 60 seconds
setInterval(() => updateAllStatusMessages(false), 60000); setInterval(() => updateAllStatusMessages(false), 60000);
}); });
client.login(token).catch(err => { client.login(token).catch(err => {
console.error('[DiscordBot] Failed to login:', err.message); console.error('[DiscordBot] Failed to login:', err.message);
}); });

View File

@@ -0,0 +1,99 @@
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
function loadConfig() {
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
}
export function getFactorioServer() {
const config = loadConfig();
return config.servers.find(s => s.type === "factorio");
}
// Default map-gen-settings structure
export function getDefaultMapGenSettings() {
return {
terrain_segmentation: 1,
water: 1,
width: 0,
height: 0,
starting_area: 1,
peaceful_mode: false,
autoplace_controls: {
coal: { frequency: 1, size: 1, richness: 1 },
stone: { frequency: 1, size: 1, richness: 1 },
"copper-ore": { frequency: 1, size: 1, richness: 1 },
"iron-ore": { frequency: 1, size: 1, richness: 1 },
"uranium-ore": { frequency: 1, size: 1, richness: 1 },
"crude-oil": { frequency: 1, size: 1, richness: 1 },
trees: { frequency: 1, size: 1, richness: 1 },
"enemy-base": { frequency: 1, size: 1, richness: 1 }
},
cliff_settings: {
name: "cliff",
cliff_elevation_0: 10,
cliff_elevation_interval: 40,
richness: 1
},
seed: null
};
}
// Factorio presets
export const FACTORIO_PRESETS = {
default: getDefaultMapGenSettings(),
"rich-resources": {
...getDefaultMapGenSettings(),
autoplace_controls: {
coal: { frequency: 1, size: 1, richness: 2 },
stone: { frequency: 1, size: 1, richness: 2 },
"copper-ore": { frequency: 1, size: 1, richness: 2 },
"iron-ore": { frequency: 1, size: 1, richness: 2 },
"uranium-ore": { frequency: 1, size: 1, richness: 2 },
"crude-oil": { frequency: 1, size: 1, richness: 2 },
trees: { frequency: 1, size: 1, richness: 1 },
"enemy-base": { frequency: 1, size: 1, richness: 1 }
}
},
"rail-world": {
...getDefaultMapGenSettings(),
autoplace_controls: {
coal: { frequency: 0.33, size: 3, richness: 1 },
stone: { frequency: 0.33, size: 3, richness: 1 },
"copper-ore": { frequency: 0.33, size: 3, richness: 1 },
"iron-ore": { frequency: 0.33, size: 3, richness: 1 },
"uranium-ore": { frequency: 0.33, size: 3, richness: 1 },
"crude-oil": { frequency: 0.33, size: 3, richness: 1 },
trees: { frequency: 1, size: 1, richness: 1 },
"enemy-base": { frequency: 0.5, size: 1, richness: 1 }
}
},
"death-world": {
...getDefaultMapGenSettings(),
autoplace_controls: {
coal: { frequency: 1, size: 1, richness: 1 },
stone: { frequency: 1, size: 1, richness: 1 },
"copper-ore": { frequency: 1, size: 1, richness: 1 },
"iron-ore": { frequency: 1, size: 1, richness: 1 },
"uranium-ore": { frequency: 1, size: 1, richness: 1 },
"crude-oil": { frequency: 1, size: 1, richness: 1 },
trees: { frequency: 1, size: 1, richness: 1 },
"enemy-base": { frequency: 2, size: 2, richness: 1 }
}
},
peaceful: {
...getDefaultMapGenSettings(),
peaceful_mode: true
}
};
export function getPresetNames() {
return Object.keys(FACTORIO_PRESETS);
}
export function getPreset(name) {
return FACTORIO_PRESETS[name] || FACTORIO_PRESETS.default;
}

View File

@@ -0,0 +1,112 @@
import fetch from "node-fetch";
const PROMETHEUS_URL = "http://localhost:9090";
const SERVER_JOBS = {
"vrising": "vrising",
"factorio": "factorio",
"minecraft": "minecraft",
"zomboid": "zomboid",
"palworld": "palworld",
"terraria": "terraria",
"openttd": "openttd"
};
export async function queryPrometheus(query) {
const url = `${PROMETHEUS_URL}/api/v1/query?query=${encodeURIComponent(query)}`;
const res = await fetch(url);
const data = await res.json();
if (data.status !== "success") {
throw new Error(`Prometheus query failed: ${data.error}`);
}
return data.data.result;
}
export async function queryPrometheusRange(query, start, end, step) {
const url = `${PROMETHEUS_URL}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${start}&end=${end}&step=${step}`;
const res = await fetch(url);
const data = await res.json();
if (data.status !== "success") {
throw new Error(`Prometheus query failed: ${data.error}`);
}
return data.data.result;
}
export async function getServerMetricsHistory(serverId, range = "1h") {
const job = SERVER_JOBS[serverId];
if (!job) {
throw new Error(`Unknown server ID: ${serverId}`);
}
const end = Math.floor(Date.now() / 1000);
let duration, step;
switch (range) {
case "15m": duration = 15 * 60; step = 15; break;
case "1h": duration = 60 * 60; step = 60; break;
case "6h": duration = 6 * 60 * 60; step = 300; break;
case "24h": duration = 24 * 60 * 60; step = 900; break;
default: duration = 60 * 60; step = 60;
}
const start = end - duration;
const cpuQuery = `100 - (avg by(instance) (irate(node_cpu_seconds_total{job="${job}",mode="idle"}[5m])) * 100)`;
const memQuery = `100 * (1 - ((node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"}) / node_memory_MemTotal_bytes{job="${job}"}))`;
const netRxQuery = `sum(irate(node_network_receive_bytes_total{job="${job}",device!~"lo|veth.*|docker.*|br-.*"}[5m]))`;
const netTxQuery = `sum(irate(node_network_transmit_bytes_total{job="${job}",device!~"lo|veth.*|docker.*|br-.*"}[5m]))`;
try {
const [cpuResult, memResult, netRxResult, netTxResult] = await Promise.all([
queryPrometheusRange(cpuQuery, start, end, step),
queryPrometheusRange(memQuery, start, end, step),
queryPrometheusRange(netRxQuery, start, end, step),
queryPrometheusRange(netTxQuery, start, end, step)
]);
const cpu = cpuResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
const memory = memResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
const networkRx = netRxResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
const networkTx = netTxResult[0]?.values?.map(([ts, val]) => ({ timestamp: ts * 1000, value: parseFloat(val) || 0 })) || [];
return { cpu, memory, networkRx, networkTx };
} catch (error) {
console.error("Prometheus query error:", error);
return { cpu: [], memory: [], networkRx: [], networkTx: [] };
}
}
export async function getCurrentMetrics(serverId) {
const job = SERVER_JOBS[serverId];
if (!job) {
throw new Error(`Unknown server ID: ${serverId}`);
}
const cpuQuery = `100 - (avg by(instance) (irate(node_cpu_seconds_total{job="${job}",mode="idle"}[5m])) * 100)`;
const memPercentQuery = `100 * (1 - ((node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"}) / node_memory_MemTotal_bytes{job="${job}"}))`;
const memUsedQuery = `node_memory_MemTotal_bytes{job="${job}"} - (node_memory_MemAvailable_bytes{job="${job}"} or node_memory_MemFree_bytes{job="${job}"})`;
const memTotalQuery = `node_memory_MemTotal_bytes{job="${job}"}`;
const uptimeQuery = `node_time_seconds{job="${job}"} - node_boot_time_seconds{job="${job}"}`;
const cpuCoresQuery = `count(node_cpu_seconds_total{job="${job}",mode="idle"})`;
try {
const [cpuResult, memPercentResult, memUsedResult, memTotalResult, uptimeResult, cpuCoresResult] = await Promise.all([
queryPrometheus(cpuQuery),
queryPrometheus(memPercentQuery),
queryPrometheus(memUsedQuery),
queryPrometheus(memTotalQuery),
queryPrometheus(uptimeQuery),
queryPrometheus(cpuCoresQuery)
]);
return {
cpu: parseFloat(cpuResult[0]?.value?.[1]) || 0,
memory: parseFloat(memPercentResult[0]?.value?.[1]) || 0,
memoryUsed: parseFloat(memUsedResult[0]?.value?.[1]) || 0,
memoryTotal: parseFloat(memTotalResult[0]?.value?.[1]) || 0,
uptime: parseFloat(uptimeResult[0]?.value?.[1]) || 0,
cpuCores: parseInt(cpuCoresResult[0]?.value?.[1]) || 1
};
} catch (error) {
console.error("Prometheus current metrics error:", error);
return { cpu: 0, memory: 0, memoryUsed: 0, memoryTotal: 0, uptime: 0, cpuCores: 1 };
}
}

View File

@@ -82,6 +82,18 @@ export async function getServerStatus(server) {
if (status === 'activating' || status === 'reloading') return 'starting'; if (status === 'activating' || status === 'reloading') return 'starting';
if (status === 'deactivating') return 'stopping'; if (status === 'deactivating') return 'stopping';
return 'offline'; return 'offline';
} else if (server.runtime === 'pm2') {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
const result = await ssh.execCommand(nvmPrefix + "pm2 jlist");
try {
const processes = JSON.parse(result.stdout);
const proc = processes.find(p => p.name === server.serviceName);
if (!proc) return "offline";
if (proc.pm2_env.status === "online") return "online";
if (proc.pm2_env.status === "launching") return "starting";
if (proc.pm2_env.status === "stopping") return "stopping";
return "offline";
} catch { return "offline"; }
} else { } else {
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`); const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
if (result.code === 0) { if (result.code === 0) {
@@ -126,7 +138,10 @@ export async function startServer(server, options = {}) {
await ssh.execCommand(`docker start ${server.containerName}`); await ssh.execCommand(`docker start ${server.containerName}`);
} }
} else if (server.runtime === 'systemd') { } else if (server.runtime === 'systemd') {
await ssh.execCommand(`systemctl start ${server.serviceName}`); const sudoCmd = server.external ? "sudo " : ""; await ssh.execCommand(`${sudoCmd}systemctl start ${server.serviceName}`);
} else if (server.runtime === 'pm2') {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
await ssh.execCommand(nvmPrefix + "pm2 start " + server.serviceName);
} else { } else {
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`); await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
@@ -140,7 +155,10 @@ export async function stopServer(server) {
if (server.runtime === 'docker') { if (server.runtime === 'docker') {
await ssh.execCommand(`docker stop ${server.containerName}`); await ssh.execCommand(`docker stop ${server.containerName}`);
} else if (server.runtime === 'systemd') { } else if (server.runtime === 'systemd') {
await ssh.execCommand(`systemctl stop ${server.serviceName}`); const sudoCmd2 = server.external ? "sudo " : ""; await ssh.execCommand(`${sudoCmd2}systemctl stop ${server.serviceName}`);
} else if (server.runtime === 'pm2') {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
await ssh.execCommand(nvmPrefix + "pm2 stop " + server.serviceName);
} else { } else {
// Different stop commands per server type // Different stop commands per server type
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop'; const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
@@ -176,6 +194,10 @@ export async function getConsoleLog(server, lines = 50) {
} else if (server.runtime === 'systemd') { } else if (server.runtime === 'systemd') {
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`); const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
return result.stdout || result.stderr; return result.stdout || result.stderr;
} else if (server.runtime === 'pm2') {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
const result = await ssh.execCommand(nvmPrefix + "pm2 logs " + server.serviceName + " --lines " + lines + " --nostream");
return result.stdout || result.stderr;
} else if (server.logFile) { } else if (server.logFile) {
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`); const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
return result.stdout; return result.stdout;
@@ -203,6 +225,17 @@ export async function getProcessUptime(server) {
if (!isNaN(startEpoch)) { if (!isNaN(startEpoch)) {
return Math.floor(Date.now() / 1000) - startEpoch; return Math.floor(Date.now() / 1000) - startEpoch;
} }
} else if (server.runtime === "pm2") {
const nvmPrefix = "source ~/.nvm/nvm.sh && ";
const result = await ssh.execCommand(nvmPrefix + "pm2 jlist");
try {
const processes = JSON.parse(result.stdout);
const proc = processes.find(p => p.name === server.serviceName);
if (proc && proc.pm2_env.pm_uptime) {
return Math.floor((Date.now() - proc.pm2_env.pm_uptime) / 1000);
}
} catch {}
return 0;
} else { } else {
const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`); const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`);
const uptime = parseInt(result.stdout.trim()); const uptime = parseInt(result.stdout.trim());
@@ -491,3 +524,67 @@ export async function writePalworldConfig(server, filename, content) {
return true; return true;
} }
// ============ TERRARIA CONFIG ============
const TERRARIA_CONFIG_PATH = "/home/terraria/serverconfig.txt";
export async function readTerrariaConfig(server) {
const ssh = await getConnection(server.host, server.sshUser);
const result = await ssh.execCommand(`cat ${TERRARIA_CONFIG_PATH}`);
if (result.code !== 0) {
throw new Error(result.stderr || "Failed to read config file");
}
return result.stdout;
}
export async function writeTerrariaConfig(server, content) {
const ssh = await getConnection(server.host, server.sshUser);
// Create backup
const backupName = `serverconfig.txt.backup.${Date.now()}`;
await ssh.execCommand(`cp ${TERRARIA_CONFIG_PATH} /home/terraria/${backupName} 2>/dev/null || true`);
// Write file using sftp
const sftp = await ssh.requestSFTP();
await new Promise((resolve, reject) => {
sftp.writeFile(TERRARIA_CONFIG_PATH, content, (err) => {
if (err) reject(err);
else resolve();
});
});
// Clean up old backups (keep last 5)
await ssh.execCommand(`ls -t /home/terraria/serverconfig.txt.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
return true;
}
// OpenTTD Config
const OPENTTD_CONFIG_PATH = "/opt/openttd/.openttd/openttd.cfg";
export async function readOpenTTDConfig(server) {
const ssh = await getConnection(server.host, server.sshUser);
const result = await ssh.execCommand(`cat ${OPENTTD_CONFIG_PATH}`);
if (result.code !== 0) {
throw new Error(result.stderr || "Failed to read config file");
}
return result.stdout;
}
export async function writeOpenTTDConfig(server, content) {
const ssh = await getConnection(server.host, server.sshUser);
const backupName = `openttd.cfg.backup.${Date.now()}`;
await ssh.execCommand(`cp ${OPENTTD_CONFIG_PATH} /opt/openttd/.openttd/${backupName} 2>/dev/null || true`);
const sftp = await ssh.requestSFTP();
await new Promise((resolve, reject) => {
sftp.writeFile(OPENTTD_CONFIG_PATH, content, (err) => {
if (err) reject(err);
else resolve();
});
});
await ssh.execCommand(`ls -t /opt/openttd/.openttd/openttd.cfg.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
return true;
}

View File

@@ -0,0 +1,493 @@
import { NodeSSH } from "node-ssh";
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const sshConnections = new Map();
const failedHosts = new Map(); // Cache failed connections
const FAILED_HOST_TTL = 60000; // 60 seconds before retry
const SSH_TIMEOUT = 5000;
function loadConfig() {
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
}
// Check if host is marked as failed (non-blocking)
export function isHostFailed(host, username = "root") {
const key = username + "@" + host;
const failedAt = failedHosts.get(key);
return failedAt && Date.now() - failedAt < FAILED_HOST_TTL;
}
// Mark host as failed
export function markHostFailed(host, username = "root") {
const key = username + "@" + host;
failedHosts.set(key, Date.now());
}
// Clear failed status
export function clearHostFailed(host, username = "root") {
const key = username + "@" + host;
failedHosts.delete(key);
}
async function getConnection(host, username = "root") {
const key = username + "@" + host;
// Check if host recently failed - throw immediately
if (isHostFailed(host, username)) {
throw new Error("Host recently unreachable");
}
if (sshConnections.has(key)) {
const conn = sshConnections.get(key);
if (conn.isConnected()) return conn;
sshConnections.delete(key);
}
const ssh = new NodeSSH();
try {
await ssh.connect({
host,
username,
privateKeyPath: "/root/.ssh/id_ed25519",
readyTimeout: SSH_TIMEOUT
});
clearHostFailed(host, username);
sshConnections.set(key, ssh);
return ssh;
} catch (err) {
markHostFailed(host, username);
throw err;
}
}
// Returns: "online", "starting", "stopping", "offline"
export async function getServerStatus(server) {
try {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === 'docker') {
const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`);
const status = result.stdout.trim();
if (status === 'running') return 'online';
if (status === 'restarting' || status === 'created') return 'starting';
if (status === 'removing' || status === 'paused') return 'stopping';
return 'offline';
} else if (server.runtime === 'systemd') {
const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`);
const status = result.stdout.trim();
if (status === 'active') return 'online';
if (status === 'activating' || status === 'reloading') return 'starting';
if (status === 'deactivating') return 'stopping';
return 'offline';
} else {
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
if (result.code === 0) {
const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`);
const uptime = parseInt(uptimeResult.stdout.trim()) || 999;
if (uptime < 60) return 'starting';
return 'online';
}
return 'offline';
}
} catch (err) {
console.error(`Failed to get status for ${server.id}:`, err.message);
return 'offline';
}
}
export async function startServer(server, options = {}) {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === 'docker') {
// Factorio with specific save
if (server.type === 'factorio' && options.save) {
const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save;
// Stop and remove existing container
await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`);
await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`);
// Start with specific save
await ssh.execCommand(`
docker run -d \
--name ${server.containerName} \
-p 34197:34197/udp \
-p 27015:27015/tcp \
-v /srv/docker/factorio/data:/factorio \
-e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \
-e TZ=Europe/Berlin \
-e PUID=845 \
-e PGID=845 \
--restart=unless-stopped \
factoriotools/factorio
`);
} else {
await ssh.execCommand(`docker start ${server.containerName}`);
}
} else if (server.runtime === 'systemd') {
await ssh.execCommand(`systemctl start ${server.serviceName}`);
} else {
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
await new Promise(resolve => setTimeout(resolve, 1000));
await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`);
}
}
export async function stopServer(server) {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === 'docker') {
await ssh.execCommand(`docker stop ${server.containerName}`);
} else if (server.runtime === 'systemd') {
await ssh.execCommand(`systemctl stop ${server.serviceName}`);
} else {
// Different stop commands per server type
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`);
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 2000));
const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
if (check.code !== 0) {
console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`);
return;
}
}
console.log(`Force killing ${server.id} after timeout`);
await ssh.execCommand(`screen -S ${server.screenName} -X quit`);
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
export async function restartServer(server) {
await stopServer(server);
await new Promise(resolve => setTimeout(resolve, 2000));
await startServer(server);
}
export async function getConsoleLog(server, lines = 50) {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === 'docker') {
const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`);
return result.stdout || result.stderr;
} else if (server.runtime === 'systemd') {
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
return result.stdout || result.stderr;
} else if (server.logFile) {
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
return result.stdout;
} else {
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`);
return result.stdout;
}
}
export async function getProcessUptime(server) {
try {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === "docker") {
const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`);
if (result.stdout.trim()) {
const startTime = new Date(result.stdout.trim());
if (!isNaN(startTime.getTime())) {
return Math.floor((Date.now() - startTime.getTime()) / 1000);
}
}
} else if (server.runtime === "systemd") {
const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`);
const startEpoch = parseInt(result.stdout.trim());
if (!isNaN(startEpoch)) {
return Math.floor(Date.now() / 1000) - startEpoch;
}
} else {
const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`);
const uptime = parseInt(result.stdout.trim());
if (!isNaN(uptime)) return uptime;
}
return 0;
} catch (err) {
console.error(`Failed to get uptime for ${server.id}:`, err.message);
return 0;
}
}
// ============ FACTORIO-SPECIFIC FUNCTIONS ============
export async function listFactorioSaves(server) {
const ssh = await getConnection(server.host, server.sshUser);
const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null');
if (result.code !== 0 || !result.stdout.trim()) {
return [];
}
const saves = [];
const lines = result.stdout.trim().split('\n');
for (const line of lines) {
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
if (match) {
const filename = match[7].split('/').pop();
// Skip autosaves
if (filename.startsWith('_autosave')) continue;
const size = match[5];
const modified = match[6];
saves.push({
name: filename.replace('.zip', ''),
filename,
size,
modified
});
}
}
return saves;
}
export async function deleteFactorioSave(server, saveName) {
const ssh = await getConnection(server.host, server.sshUser);
const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`;
// Security check - prevent path traversal
if (filename.includes('/') || filename.includes('..')) {
throw new Error('Invalid save name');
}
const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`);
if (result.code !== 0) {
throw new Error(result.stderr || 'Failed to delete save');
}
}
export async function createFactorioWorld(server, saveName, settings) {
const ssh = await getConnection(server.host, server.sshUser);
// Security check
if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) {
throw new Error("Invalid save name - no spaces or special characters allowed");
}
// Write map-gen-settings.json
const settingsJson = JSON.stringify(settings, null, 2);
await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF'
${settingsJson}
SETTINGSEOF`);
// Create new world using --create flag (correct method)
console.log(`Creating new Factorio world: ${saveName}`);
const createResult = await ssh.execCommand(`
docker run --rm \
-v /srv/docker/factorio/data:/factorio \
factoriotools/factorio \
/opt/factorio/bin/x64/factorio \
--create /factorio/saves/${saveName}.zip \
--map-gen-settings /factorio/config/map-gen-settings.json
`, { execOptions: { timeout: 120000 } });
console.log("World creation output:", createResult.stdout, createResult.stderr);
// Verify save was created
const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`);
if (checkResult.code !== 0) {
throw new Error("World creation failed - save file not found. Output: " + createResult.stderr);
}
return true;
}
export async function getFactorioCurrentSave(server) {
const ssh = await getConnection(server.host, server.sshUser);
// Check if container has SAVE_NAME env set
const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`);
if (envResult.stdout.trim()) {
const saveName = envResult.stdout.trim().replace("SAVE_NAME=", "");
return { save: saveName, source: "configured" };
}
// Otherwise find newest save file
const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1");
if (result.stdout.trim()) {
const filename = result.stdout.trim().split("/").pop().replace(".zip", "");
return { save: filename, source: "newest" };
}
return { save: null, source: null };
}
// ============ ZOMBOID CONFIG FUNCTIONS ============
const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server";
const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"];
export async function listZomboidConfigs(server) {
const ssh = await getConnection(server.host, server.sshUser);
const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`;
const result = await ssh.execCommand(cmd);
if (result.code !== 0 || !result.stdout.trim()) {
return [];
}
const files = [];
const lines = result.stdout.trim().split("\n");
for (const line of lines) {
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
if (match) {
const fullPath = match[7];
const filename = fullPath.split("/").pop();
if (!ALLOWED_CONFIG_FILES.includes(filename)) continue;
files.push({
filename,
size: parseInt(match[5]),
modified: match[6]
});
}
}
return files;
}
export async function readZomboidConfig(server, filename) {
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
throw new Error("File not allowed");
}
if (filename.includes("/") || filename.includes("..")) {
throw new Error("Invalid filename");
}
const ssh = await getConnection(server.host, server.sshUser);
const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`);
if (result.code !== 0) {
throw new Error(result.stderr || "Failed to read config file");
}
return result.stdout;
}
export async function writeZomboidConfig(server, filename, content) {
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
throw new Error("File not allowed");
}
if (filename.includes("/") || filename.includes("..")) {
throw new Error("Invalid filename");
}
const ssh = await getConnection(server.host, server.sshUser);
// Create backup
const backupName = `${filename}.backup.${Date.now()}`;
await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
// Write file using sftp
const sftp = await ssh.requestSFTP();
const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`;
await new Promise((resolve, reject) => {
sftp.writeFile(filePath, content, (err) => {
if (err) reject(err);
else resolve();
});
});
// Clean up old backups (keep last 5)
await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
return true;
}
// ============ PALWORLD CONFIG ============
const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer";
const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"];
export async function listPalworldConfigs(server) {
const ssh = await getConnection(server.host);
const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`;
const result = await ssh.execCommand(cmd);
if (result.code !== 0 || !result.stdout.trim()) {
return [];
}
const files = [];
const lines = result.stdout.trim().split("\n");
for (const line of lines) {
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
if (match) {
const fullPath = match[7];
const filename = fullPath.split("/").pop();
if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue;
files.push({
filename,
size: parseInt(match[5]),
modified: match[6]
});
}
}
return files;
}
export async function readPalworldConfig(server, filename) {
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
throw new Error("File not allowed");
}
if (filename.includes("/") || filename.includes("..")) {
throw new Error("Invalid filename");
}
const ssh = await getConnection(server.host);
const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`);
if (result.code !== 0) {
throw new Error(result.stderr || "Failed to read config file");
}
return result.stdout;
}
export async function writePalworldConfig(server, filename, content) {
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
throw new Error("File not allowed");
}
if (filename.includes("/") || filename.includes("..")) {
throw new Error("Invalid filename");
}
const ssh = await getConnection(server.host);
// Create backup
const backupName = `${filename}.backup.${Date.now()}`;
await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
// Write file using sftp
const sftp = await ssh.requestSFTP();
const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`;
await new Promise((resolve, reject) => {
sftp.writeFile(filePath, content, (err) => {
if (err) reject(err);
else resolve();
});
});
// Clean up old backups (keep last 5)
await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
return true;
}

View File

@@ -0,0 +1,493 @@
import { NodeSSH } from "node-ssh";
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const sshConnections = new Map();
const failedHosts = new Map(); // Cache failed connections
const FAILED_HOST_TTL = 60000; // 60 seconds before retry
const SSH_TIMEOUT = 5000;
function loadConfig() {
return JSON.parse(readFileSync(join(__dirname, "..", "config.json"), "utf-8"));
}
// Check if host is marked as failed (non-blocking)
export function isHostFailed(host, username = "root") {
const key = username + "@" + host;
const failedAt = failedHosts.get(key);
return failedAt && Date.now() - failedAt < FAILED_HOST_TTL;
}
// Mark host as failed
export function markHostFailed(host, username = "root") {
const key = username + "@" + host;
failedHosts.set(key, Date.now());
}
// Clear failed status
export function clearHostFailed(host, username = "root") {
const key = username + "@" + host;
failedHosts.delete(key);
}
async function getConnection(host, username = "root") {
const key = username + "@" + host;
// Check if host recently failed - throw immediately
if (isHostFailed(host, username)) {
throw new Error("Host recently unreachable");
}
if (sshConnections.has(key)) {
const conn = sshConnections.get(key);
if (conn.isConnected()) return conn;
sshConnections.delete(key);
}
const ssh = new NodeSSH();
try {
await ssh.connect({
host,
username,
privateKeyPath: "/root/.ssh/id_ed25519",
readyTimeout: SSH_TIMEOUT
});
clearHostFailed(host, username);
sshConnections.set(key, ssh);
return ssh;
} catch (err) {
markHostFailed(host, username);
throw err;
}
}
// Returns: "online", "starting", "stopping", "offline"
export async function getServerStatus(server) {
try {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === 'docker') {
const result = await ssh.execCommand(`docker inspect --format='{{.State.Status}}' ${server.containerName} 2>/dev/null`);
const status = result.stdout.trim();
if (status === 'running') return 'online';
if (status === 'restarting' || status === 'created') return 'starting';
if (status === 'removing' || status === 'paused') return 'stopping';
return 'offline';
} else if (server.runtime === 'systemd') {
const result = await ssh.execCommand(`systemctl is-active ${server.serviceName}`);
const status = result.stdout.trim();
if (status === 'active') return 'online';
if (status === 'activating' || status === 'reloading') return 'starting';
if (status === 'deactivating') return 'stopping';
return 'offline';
} else {
const result = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
if (result.code === 0) {
const uptimeResult = await ssh.execCommand(`ps -o etimes= -p $(screen -ls | grep "\\.${server.screenName}" | awk '{print $1}' | cut -d. -f1) 2>/dev/null | head -1`);
const uptime = parseInt(uptimeResult.stdout.trim()) || 999;
if (uptime < 60) return 'starting';
return 'online';
}
return 'offline';
}
} catch (err) {
console.error(`Failed to get status for ${server.id}:`, err.message);
return 'offline';
}
}
export async function startServer(server, options = {}) {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === 'docker') {
// Factorio with specific save
if (server.type === 'factorio' && options.save) {
const saveName = options.save.endsWith('.zip') ? options.save.replace('.zip', '') : options.save;
// Stop and remove existing container
await ssh.execCommand(`docker stop ${server.containerName} 2>/dev/null || true`);
await ssh.execCommand(`docker rm ${server.containerName} 2>/dev/null || true`);
// Start with specific save
await ssh.execCommand(`
docker run -d \
--name ${server.containerName} \
-p 34197:34197/udp \
-p 27015:27015/tcp \
-v /srv/docker/factorio/data:/factorio \
-e SAVE_NAME=${saveName} -e LOAD_LATEST_SAVE=false \
-e TZ=Europe/Berlin \
-e PUID=845 \
-e PGID=845 \
--restart=unless-stopped \
factoriotools/factorio
`);
} else {
await ssh.execCommand(`docker start ${server.containerName}`);
}
} else if (server.runtime === 'systemd') {
await ssh.execCommand(`systemctl start ${server.serviceName}`);
} else {
await ssh.execCommand(`screen -S ${server.screenName} -X quit 2>/dev/null`);
await new Promise(resolve => setTimeout(resolve, 1000));
await ssh.execCommand(`cd ${server.workDir} && screen -dmS ${server.screenName} ${server.startCmd}`);
}
}
export async function stopServer(server) {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === 'docker') {
await ssh.execCommand(`docker stop ${server.containerName}`);
} else if (server.runtime === 'systemd') {
await ssh.execCommand(`systemctl stop ${server.serviceName}`);
} else {
// Different stop commands per server type
const stopCmd = server.type === 'zomboid' ? 'quit' : 'stop';
await ssh.execCommand(`screen -S ${server.screenName} -p 0 -X stuff '${stopCmd}\n'`);
for (let i = 0; i < 30; i++) {
await new Promise(resolve => setTimeout(resolve, 2000));
const check = await ssh.execCommand(`screen -ls | grep -E "\\.${server.screenName}[[:space:]]"`);
if (check.code !== 0) {
console.log(`Server ${server.id} stopped after ${(i + 1) * 2} seconds`);
return;
}
}
console.log(`Force killing ${server.id} after timeout`);
await ssh.execCommand(`screen -S ${server.screenName} -X quit`);
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
export async function restartServer(server) {
await stopServer(server);
await new Promise(resolve => setTimeout(resolve, 2000));
await startServer(server);
}
export async function getConsoleLog(server, lines = 50) {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === 'docker') {
const result = await ssh.execCommand(`/usr/local/bin/docker-logs-tz ${server.containerName} ${lines}`);
return result.stdout || result.stderr;
} else if (server.runtime === 'systemd') {
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/VRisingServer.log 2>/dev/null || journalctl -u ${server.serviceName} -n ${lines} --no-pager`);
return result.stdout || result.stderr;
} else if (server.logFile) {
const result = await ssh.execCommand(`tail -n ${lines} ${server.logFile} 2>/dev/null || echo No log file found`);
return result.stdout;
} else {
const result = await ssh.execCommand(`tail -n ${lines} ${server.workDir}/logs/latest.log 2>/dev/null || echo No log file found`);
return result.stdout;
}
}
export async function getProcessUptime(server) {
try {
const ssh = await getConnection(server.host, server.sshUser);
if (server.runtime === "docker") {
const result = await ssh.execCommand(`docker inspect --format="{{.State.StartedAt}}" ${server.containerName} 2>/dev/null`);
if (result.stdout.trim()) {
const startTime = new Date(result.stdout.trim());
if (!isNaN(startTime.getTime())) {
return Math.floor((Date.now() - startTime.getTime()) / 1000);
}
}
} else if (server.runtime === "systemd") {
const result = await ssh.execCommand(`systemctl show ${server.serviceName} --property=ActiveEnterTimestamp | cut -d= -f2 | xargs -I{} date -d "{}" +%s 2>/dev/null`);
const startEpoch = parseInt(result.stdout.trim());
if (!isNaN(startEpoch)) {
return Math.floor(Date.now() / 1000) - startEpoch;
}
} else {
const result = await ssh.execCommand(`ps -o etimes= -p $(pgrep -f "SCREEN.*${server.screenName}") 2>/dev/null | head -1`);
const uptime = parseInt(result.stdout.trim());
if (!isNaN(uptime)) return uptime;
}
return 0;
} catch (err) {
console.error(`Failed to get uptime for ${server.id}:`, err.message);
return 0;
}
}
// ============ FACTORIO-SPECIFIC FUNCTIONS ============
export async function listFactorioSaves(server) {
const ssh = await getConnection(server.host, server.sshUser);
const result = await ssh.execCommand('ls -la /srv/docker/factorio/data/saves/*.zip 2>/dev/null');
if (result.code !== 0 || !result.stdout.trim()) {
return [];
}
const saves = [];
const lines = result.stdout.trim().split('\n');
for (const line of lines) {
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
if (match) {
const filename = match[7].split('/').pop();
// Skip autosaves
if (filename.startsWith('_autosave')) continue;
const size = match[5];
const modified = match[6];
saves.push({
name: filename.replace('.zip', ''),
filename,
size,
modified
});
}
}
return saves;
}
export async function deleteFactorioSave(server, saveName) {
const ssh = await getConnection(server.host, server.sshUser);
const filename = saveName.endsWith('.zip') ? saveName : `${saveName}.zip`;
// Security check - prevent path traversal
if (filename.includes('/') || filename.includes('..')) {
throw new Error('Invalid save name');
}
const result = await ssh.execCommand(`rm /srv/docker/factorio/data/saves/${filename}`);
if (result.code !== 0) {
throw new Error(result.stderr || 'Failed to delete save');
}
}
export async function createFactorioWorld(server, saveName, settings) {
const ssh = await getConnection(server.host, server.sshUser);
// Security check
if (saveName.includes("/") || saveName.includes("..") || saveName.includes(" ")) {
throw new Error("Invalid save name - no spaces or special characters allowed");
}
// Write map-gen-settings.json
const settingsJson = JSON.stringify(settings, null, 2);
await ssh.execCommand(`cat > /srv/docker/factorio/data/config/map-gen-settings.json << 'SETTINGSEOF'
${settingsJson}
SETTINGSEOF`);
// Create new world using --create flag (correct method)
console.log(`Creating new Factorio world: ${saveName}`);
const createResult = await ssh.execCommand(`
docker run --rm \
-v /srv/docker/factorio/data:/factorio \
factoriotools/factorio \
/opt/factorio/bin/x64/factorio \
--create /factorio/saves/${saveName}.zip \
--map-gen-settings /factorio/config/map-gen-settings.json
`, { execOptions: { timeout: 120000 } });
console.log("World creation output:", createResult.stdout, createResult.stderr);
// Verify save was created
const checkResult = await ssh.execCommand(`ls /srv/docker/factorio/data/saves/${saveName}.zip`);
if (checkResult.code !== 0) {
throw new Error("World creation failed - save file not found. Output: " + createResult.stderr);
}
return true;
}
export async function getFactorioCurrentSave(server) {
const ssh = await getConnection(server.host, server.sshUser);
// Check if container has SAVE_NAME env set
const envResult = await ssh.execCommand(`docker inspect --format="{{range .Config.Env}}{{println .}}{{end}}" ${server.containerName} 2>/dev/null | grep "^SAVE_NAME="`);
if (envResult.stdout.trim()) {
const saveName = envResult.stdout.trim().replace("SAVE_NAME=", "");
return { save: saveName, source: "configured" };
}
// Otherwise find newest save file
const result = await ssh.execCommand("ls -t /srv/docker/factorio/data/saves/*.zip 2>/dev/null | head -1");
if (result.stdout.trim()) {
const filename = result.stdout.trim().split("/").pop().replace(".zip", "");
return { save: filename, source: "newest" };
}
return { save: null, source: null };
}
// ============ ZOMBOID CONFIG FUNCTIONS ============
const ZOMBOID_CONFIG_PATH = "/home/pzuser/Zomboid/Server";
const ALLOWED_CONFIG_FILES = ["Project.ini", "Project_SandboxVars.lua", "Project_spawnpoints.lua", "Project_spawnregions.lua"];
export async function listZomboidConfigs(server) {
const ssh = await getConnection(server.host, server.sshUser);
const cmd = `ls -la ${ZOMBOID_CONFIG_PATH}/*.ini ${ZOMBOID_CONFIG_PATH}/*.lua 2>/dev/null`;
const result = await ssh.execCommand(cmd);
if (result.code !== 0 || !result.stdout.trim()) {
return [];
}
const files = [];
const lines = result.stdout.trim().split("\n");
for (const line of lines) {
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
if (match) {
const fullPath = match[7];
const filename = fullPath.split("/").pop();
if (!ALLOWED_CONFIG_FILES.includes(filename)) continue;
files.push({
filename,
size: parseInt(match[5]),
modified: match[6]
});
}
}
return files;
}
export async function readZomboidConfig(server, filename) {
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
throw new Error("File not allowed");
}
if (filename.includes("/") || filename.includes("..")) {
throw new Error("Invalid filename");
}
const ssh = await getConnection(server.host, server.sshUser);
const result = await ssh.execCommand(`cat ${ZOMBOID_CONFIG_PATH}/${filename}`);
if (result.code !== 0) {
throw new Error(result.stderr || "Failed to read config file");
}
return result.stdout;
}
export async function writeZomboidConfig(server, filename, content) {
if (!ALLOWED_CONFIG_FILES.includes(filename)) {
throw new Error("File not allowed");
}
if (filename.includes("/") || filename.includes("..")) {
throw new Error("Invalid filename");
}
const ssh = await getConnection(server.host, server.sshUser);
// Create backup
const backupName = `${filename}.backup.${Date.now()}`;
await ssh.execCommand(`cp ${ZOMBOID_CONFIG_PATH}/${filename} ${ZOMBOID_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
// Write file using sftp
const sftp = await ssh.requestSFTP();
const filePath = `${ZOMBOID_CONFIG_PATH}/${filename}`;
await new Promise((resolve, reject) => {
sftp.writeFile(filePath, content, (err) => {
if (err) reject(err);
else resolve();
});
});
// Clean up old backups (keep last 5)
await ssh.execCommand(`ls -t ${ZOMBOID_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
return true;
}
// ============ PALWORLD CONFIG ============
const PALWORLD_CONFIG_PATH = "/opt/palworld/Pal/Saved/Config/LinuxServer";
const PALWORLD_ALLOWED_FILES = ["PalWorldSettings.ini", "Engine.ini", "GameUserSettings.ini"];
export async function listPalworldConfigs(server) {
const ssh = await getConnection(server.host);
const cmd = `ls -la ${PALWORLD_CONFIG_PATH}/*.ini 2>/dev/null`;
const result = await ssh.execCommand(cmd);
if (result.code !== 0 || !result.stdout.trim()) {
return [];
}
const files = [];
const lines = result.stdout.trim().split("\n");
for (const line of lines) {
const match = line.match(/(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\S+\s+\d+\s+[\d:]+)\s+(.+)$/);
if (match) {
const fullPath = match[7];
const filename = fullPath.split("/").pop();
if (!PALWORLD_ALLOWED_FILES.includes(filename)) continue;
files.push({
filename,
size: parseInt(match[5]),
modified: match[6]
});
}
}
return files;
}
export async function readPalworldConfig(server, filename) {
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
throw new Error("File not allowed");
}
if (filename.includes("/") || filename.includes("..")) {
throw new Error("Invalid filename");
}
const ssh = await getConnection(server.host);
const result = await ssh.execCommand(`cat ${PALWORLD_CONFIG_PATH}/${filename}`);
if (result.code !== 0) {
throw new Error(result.stderr || "Failed to read config file");
}
return result.stdout;
}
export async function writePalworldConfig(server, filename, content) {
if (!PALWORLD_ALLOWED_FILES.includes(filename)) {
throw new Error("File not allowed");
}
if (filename.includes("/") || filename.includes("..")) {
throw new Error("Invalid filename");
}
const ssh = await getConnection(server.host);
// Create backup
const backupName = `${filename}.backup.${Date.now()}`;
await ssh.execCommand(`cp ${PALWORLD_CONFIG_PATH}/${filename} ${PALWORLD_CONFIG_PATH}/${backupName} 2>/dev/null || true`);
// Write file using sftp
const sftp = await ssh.requestSFTP();
const filePath = `${PALWORLD_CONFIG_PATH}/${filename}`;
await new Promise((resolve, reject) => {
sftp.writeFile(filePath, content, (err) => {
if (err) reject(err);
else resolve();
});
});
// Clean up old backups (keep last 5)
await ssh.execCommand(`ls -t ${PALWORLD_CONFIG_PATH}/${filename}.backup.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true`);
return true;
}

View File

@@ -78,6 +78,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -1792,6 +1793,7 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1839,6 +1841,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1981,6 +1984,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2397,6 +2401,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -3419,6 +3424,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3446,6 +3452,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3487,6 +3494,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3496,6 +3504,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -3515,6 +3524,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@@ -3615,7 +3625,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@@ -3779,7 +3790,8 @@
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/tailwindcss-animate": { "node_modules/tailwindcss-animate": {
"version": "1.0.7", "version": "1.0.7",
@@ -3918,6 +3930,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -4039,6 +4052,7 @@
"integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -3,6 +3,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { UserProvider } from './context/UserContext' import { UserProvider } from './context/UserContext'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import ServerDetail from './pages/ServerDetail' import ServerDetail from './pages/ServerDetail'
import LoginPage from './pages/LoginPage'
import AuthCallback from './pages/AuthCallback'
export default function App() { export default function App() {
const [token, setToken] = useState(localStorage.getItem('gsm_token')) const [token, setToken] = useState(localStorage.getItem('gsm_token'))
@@ -18,11 +20,13 @@ export default function App() {
} }
return ( return (
<UserProvider token={token}> <UserProvider token={token} onLogout={handleLogout}>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Dashboard onLogin={handleLogin} onLogout={handleLogout} />} /> <Route path="/" element={token ? <Dashboard onLogout={handleLogout} /> : <Navigate to="/login" replace />} />
<Route path="/server/:serverId" element={<ServerDetail onLogin={handleLogin} onLogout={handleLogout} />} /> <Route path="/server/:serverId" element={token ? <ServerDetail onLogout={handleLogout} /> : <Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage onLogin={handleLogin} />} />
<Route path="/auth/callback" element={<AuthCallback onLogin={handleLogin} />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -1,78 +1,36 @@
import { useState } from 'react' const API_URL = import.meta.env.VITE_API_URL || '/api'
import { login } from '../api'
export default function LoginModal({ onLogin, onClose }) { export default function LoginModal({ onClose }) {
const [username, setUsername] = useState('') const handleDiscordLogin = () => {
const [password, setPassword] = useState('') window.location.href = `${API_URL}/auth/discord`
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const { token } = await login(username, password)
onLogin(token)
onClose()
} catch (err) {
setError(err.message || 'Login failed')
} finally {
setLoading(false)
}
} }
return ( return (
<div className="modal-backdrop" onClick={onClose}> <div className="modal-backdrop" onClick={onClose}>
<div className="modal fade-in-scale" onClick={(e) => e.stopPropagation()}> <div className="modal fade-in-scale" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2 className="modal-title">Sign in</h2> <h2 className="modal-title">Anmelden</h2>
<button onClick={onClose} className="btn btn-ghost"> <button onClick={onClose} className="btn btn-ghost">
&times; &times;
</button> </button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<form onSubmit={handleSubmit} className="space-y-4"> <p className="text-neutral-400 text-sm mb-4">
{error && ( Melde dich mit Discord an um erweiterte Funktionen zu nutzen.
<div className="alert alert-error"> </p>
{error}
</div>
)}
<div className="form-group"> <button
<label className="form-label">Username</label> onClick={handleDiscordLogin}
<input className="w-full flex items-center justify-center gap-3 text-white font-medium py-3 px-6 rounded-lg transition-all duration-200 hover:scale-[1.02]"
type="text" style={{
value={username} background: 'linear-gradient(135deg, #f144e8 0%, #7128d7 100%)',
onChange={(e) => setUsername(e.target.value)} }}
className="input" >
placeholder="Enter username" <svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
required <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"/>
autoFocus </svg>
/> Mit Discord anmelden
</div> </button>
<div className="form-group">
<label className="form-label">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
placeholder="Enter password"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="btn btn-primary w-full"
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,10 @@ import { useState, useEffect } from 'react'
import { useUser } from '../context/UserContext' import { useUser } from '../context/UserContext'
import { getUsers } from '../api' import { getUsers } from '../api'
function getDiscordProfileUrl(discordId) {
return `https://discord.com/users/${discordId}`
}
export default function UserManagement({ onClose }) { export default function UserManagement({ onClose }) {
const { token } = useUser() const { token } = useUser()
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
@@ -66,26 +70,54 @@ export default function UserManagement({ onClose }) {
<div className="text-center py-4 text-neutral-400">Loading users...</div> <div className="text-center py-4 text-neutral-400">Loading users...</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{users.map((user) => ( {users.map((user) => {
<div key={user.id} className="card p-3 flex items-center gap-3"> const discordId = user.discord_id || user.discordId
<img const profileUrl = discordId ? getDiscordProfileUrl(discordId) : null
src={getAvatarUrl(user)}
alt={user.username} return (
className="w-10 h-10 rounded-full" <div key={user.id} className="card p-3 flex items-center gap-3">
/> {profileUrl ? (
<div className="flex-1 min-w-0"> <a
<div className="text-white font-medium truncate">{user.username}</div> href={profileUrl}
<div className="flex items-center gap-2 text-xs"> target="_blank"
<span className={roleColors[user.role] || 'text-neutral-500'}> rel="noopener noreferrer"
{roleLabels[user.role]} className="flex-shrink-0"
</span> >
{(user.discord_id || user.discordId) && ( <img
<span className="text-neutral-600">ID: {user.discord_id || user.discordId}</span> src={getAvatarUrl(user)}
alt={user.username}
className="w-10 h-10 rounded-full hover:ring-2 hover:ring-blue-500 transition-all"
/>
</a>
) : (
<img
src={getAvatarUrl(user)}
alt={user.username}
className="w-10 h-10 rounded-full"
/>
)}
<div className="flex-1 min-w-0">
{profileUrl ? (
<a
href={profileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-white font-medium truncate block hover:text-blue-400 transition-colors"
>
{user.username}
</a>
) : (
<div className="text-white font-medium truncate">{user.username}</div>
)} )}
<div className="flex items-center gap-2 text-xs">
<span className={roleColors[user.role] || 'text-neutral-500'}>
{roleLabels[user.role]}
</span>
</div>
</div> </div>
</div> </div>
</div> )
))} })}
</div> </div>
)} )}

View File

@@ -0,0 +1,28 @@
import { useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
export default function AuthCallback({ onLogin }) {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
useEffect(() => {
const token = searchParams.get('token')
if (token) {
onLogin(token)
navigate('/', { replace: true })
} else {
// No token received, redirect to login with error
navigate('/login?error=oauth_failed', { replace: true })
}
}, [searchParams, onLogin, navigate])
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-gray-400">Anmeldung wird verarbeitet...</p>
</div>
</div>
)
}

View File

@@ -4,10 +4,10 @@ import { getServers, getAllDisplaySettings } from '../api'
import { useUser } from '../context/UserContext' import { useUser } from '../context/UserContext'
import ServerCard from '../components/ServerCard' import ServerCard from '../components/ServerCard'
import UserManagement from '../components/UserManagement' import UserManagement from '../components/UserManagement'
import LoginModal from '../components/LoginModal'
import ActivityLog from '../components/ActivityLog' import ActivityLog from '../components/ActivityLog'
import LoginModal from '../components/LoginModal'
export default function Dashboard({ onLogin, onLogout }) { export default function Dashboard({ onLogout }) {
const navigate = useNavigate() const navigate = useNavigate()
const { user, token, loading: userLoading, isSuperadmin, role } = useUser() const { user, token, loading: userLoading, isSuperadmin, role } = useUser()
const [servers, setServers] = useState([]) const [servers, setServers] = useState([])
@@ -15,10 +15,12 @@ export default function Dashboard({ onLogin, onLogout }) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showUserMgmt, setShowUserMgmt] = useState(false) const [showUserMgmt, setShowUserMgmt] = useState(false)
const [showLogin, setShowLogin] = useState(false)
const [showActivityLog, setShowActivityLog] = useState(false) const [showActivityLog, setShowActivityLog] = useState(false)
const [showLogin, setShowLogin] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const isGuest = user?.isGuest || role === 'guest'
const isAuthenticated = !!token const isAuthenticated = !!token
const fetchServers = async () => { const fetchServers = async () => {
@@ -93,39 +95,53 @@ export default function Dashboard({ onLogin, onLogout }) {
<> <>
{/* Desktop Navigation */} {/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-6"> <div className="hidden md:flex items-center gap-6">
{user?.avatar && user?.discordId && ( {isGuest ? (
<img
src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`}
alt="Avatar"
className="w-8 h-8 rounded-full"
/>
)}
<div className="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 && (
<> <>
<span className="text-sm text-neutral-400">Gast</span>
<button <button
onClick={() => setShowUserMgmt(true)} onClick={() => setShowLogin(true)}
className="btn btn-ghost" className="btn btn-primary"
> >
Benutzer Anmelden
</button> </button>
</>
) : (
<>
{user?.avatar && user?.discordId && (
<img
src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`}
alt="Avatar"
className="w-8 h-8 rounded-full"
/>
)}
<div className="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={() => setShowActivityLog(true)}
className="btn btn-ghost"
>
Logs
</button>
</>
)}
<button <button
onClick={() => setShowActivityLog(true)} onClick={onLogout}
className="btn btn-ghost" className="btn btn-outline"
> >
Logs Abmelden
</button> </button>
</> </>
)} )}
<button
onClick={onLogout}
className="btn btn-outline"
>
Abmelden
</button>
</div> </div>
{/* Mobile Burger Button */} {/* Mobile Burger Button */}
@@ -147,7 +163,7 @@ export default function Dashboard({ onLogin, onLogout }) {
</> </>
) : ( ) : (
<button <button
onClick={() => setShowLogin(true)} onClick={() => navigate('/login')}
className="btn btn-primary" className="btn btn-primary"
> >
Anmelden Anmelden
@@ -161,7 +177,7 @@ export default function Dashboard({ onLogin, onLogout }) {
<div className="md:hidden border-t border-neutral-800 py-4 mt-4 animate-slideDown"> <div className="md:hidden border-t border-neutral-800 py-4 mt-4 animate-slideDown">
<div className="flex items-center justify-between mb-3 px-1"> <div className="flex items-center justify-between mb-3 px-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user?.avatar && user?.discordId && ( {!isGuest && user?.avatar && user?.discordId && (
<img <img
src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`} src={`https://cdn.discordapp.com/avatars/${user.discordId}/${user.avatar}.png?size=64`}
alt="Avatar" alt="Avatar"
@@ -169,8 +185,8 @@ export default function Dashboard({ onLogin, onLogout }) {
/> />
)} )}
<div> <div>
<div className="text-sm text-white">{user?.username}</div> <div className="text-sm text-white">{isGuest ? 'Gast' : user?.username}</div>
<div className="text-xs text-neutral-500">{roleLabels[role]}</div> {!isGuest && <div className="text-xs text-neutral-500">{roleLabels[role]}</div>}
</div> </div>
</div> </div>
<div className="text-sm text-neutral-400 text-right"> <div className="text-sm text-neutral-400 text-right">
@@ -179,28 +195,39 @@ export default function Dashboard({ onLogin, onLogout }) {
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{isSuperadmin && ( {isGuest ? (
<button
onClick={() => { setShowLogin(true); setMobileMenuOpen(false); }}
className="btn btn-primary justify-start"
>
Mit Discord anmelden
</button>
) : (
<> <>
{isSuperadmin && (
<>
<button
onClick={() => { setShowUserMgmt(true); setMobileMenuOpen(false); }}
className="btn btn-ghost justify-start"
>
Benutzer
</button>
<button
onClick={() => { setShowActivityLog(true); setMobileMenuOpen(false); }}
className="btn btn-ghost justify-start"
>
Logs
</button>
</>
)}
<button <button
onClick={() => { setShowUserMgmt(true); setMobileMenuOpen(false); }} onClick={() => { onLogout(); setMobileMenuOpen(false); }}
className="btn btn-ghost justify-start" className="btn btn-outline justify-start"
> >
Benutzer Abmelden
</button>
<button
onClick={() => { setShowActivityLog(true); setMobileMenuOpen(false); }}
className="btn btn-ghost justify-start"
>
Logs
</button> </button>
</> </>
)} )}
<button
onClick={() => { onLogout(); setMobileMenuOpen(false); }}
className="btn btn-outline justify-start"
>
Abmelden
</button>
</div> </div>
</div> </div>
)} )}
@@ -274,7 +301,7 @@ export default function Dashboard({ onLogin, onLogout }) {
<ActivityLog onClose={() => setShowActivityLog(false)} /> <ActivityLog onClose={() => setShowActivityLog(false)} />
)} )}
{showLogin && ( {showLogin && (
<LoginModal onLogin={onLogin} onClose={() => setShowLogin(false)} /> <LoginModal onClose={() => setShowLogin(false)} />
)} )}
</div> </div>
) )

View File

@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { useUser } from '../context/UserContext'
const API_URL = import.meta.env.VITE_API_URL || '/api'
const ERROR_MESSAGES = {
discord_denied: 'Discord-Anmeldung wurde abgebrochen.',
no_code: 'Kein Autorisierungscode erhalten.',
not_in_guild: 'Du bist nicht Mitglied eines berechtigten Discord-Servers.',
oauth_failed: 'Discord-Anmeldung fehlgeschlagen. Bitte versuche es erneut.',
}
export default function LoginPage({ onLogin }) {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { user, loading } = useUser()
const [error, setError] = useState(null)
const [guestLoading, setGuestLoading] = useState(false)
useEffect(() => {
if (!loading && user) {
navigate('/', { replace: true })
}
}, [user, loading, navigate])
useEffect(() => {
const errorCode = searchParams.get('error')
if (errorCode) {
setError(ERROR_MESSAGES[errorCode] || 'Ein unbekannter Fehler ist aufgetreten.')
}
}, [searchParams])
const handleDiscordLogin = () => {
window.location.href = `${API_URL}/auth/discord`
}
const handleGuestLogin = async () => {
setGuestLoading(true)
try {
const res = await fetch(`${API_URL}/auth/guest`, { method: 'POST' })
const data = await res.json()
if (data.token) {
onLogin(data.token)
navigate('/', { replace: true })
}
} catch (err) {
setError('Gast-Anmeldung fehlgeschlagen.')
} finally {
setGuestLoading(false)
}
}
if (loading) {
return (
<div className="min-h-screen bg-black flex items-center justify-center">
<div className="text-neutral-400">Laden...</div>
</div>
)
}
return (
<div className="min-h-screen bg-black flex flex-col items-center justify-center p-4 relative overflow-hidden">
{/* Pulsing Spotlight */}
<div
className="absolute w-[800px] h-[800px] rounded-full"
style={{
background: 'radial-gradient(circle, rgba(241, 68, 232, 0.3) 0%, rgba(113, 40, 215, 0.2) 40%, transparent 70%)',
filter: 'blur(60px)',
animation: 'pulse 4s ease-in-out infinite',
}}
/>
<style>{`
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.15);
opacity: 1;
}
}
`}</style>
<div className="flex flex-col items-center relative z-10">
{/* Logo */}
<a
href="https://zeasy.software"
target="_blank"
rel="noopener noreferrer"
className="relative block group mb-4"
>
<img
src="/navbarlogoweiß.png"
alt="Zeasy"
className="h-20 transition-opacity duration-300 group-hover:opacity-0"
/>
<img
src="/navbarlogograuer.png"
alt="Zeasy"
className="h-20 absolute top-0 left-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
/>
</a>
<p className="text-white text-xl font-semibold mb-8">Gameserver Management</p>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 px-4 py-3 rounded-lg mb-6 text-sm text-center">
{error}
</div>
)}
<div className="flex flex-col items-center gap-3">
<button
onClick={handleDiscordLogin}
className="flex items-center justify-center gap-3 text-white font-medium py-3 px-6 rounded-lg transition-all duration-200 hover:scale-[1.02] hover:shadow-lg"
style={{
background: 'linear-gradient(135deg, #f144e8 0%, #7128d7 100%)',
}}
>
<svg className="w-5 h-5" 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>
<button
onClick={handleGuestLogin}
disabled={guestLoading}
className="text-neutral-400 hover:text-white text-sm transition-colors"
>
{guestLoading ? 'Laden...' : 'Als Gast fortfahren'}
</button>
</div>
</div>
</div>
)
}